Cours de Python / Projet final : gérer des dépenses en ligne de commande avec SQLite
Ce projet réunit les acquis du cours dans une application en ligne de commande : saisir une dépense, l’enregistrer dans SQLite, consulter les dépenses et calculer des totaux par catégorie. L’initialisation, la validation et les transactions sont explicites afin de vérifier aussi bien les résultats corrects que les entrées refusées.
Définir le périmètre et préparer un dossier neuf
Chaque dépense possède une date réelle au format YYYY-MM-DD, une catégorie non vide et un montant entier de 0 à 1 000 000 000 000 inclus. Les montants de cet exercice sont exprimés en wons coréens entiers, sans conversion de devise. Le programme n’effectue aucun paiement, ne se connecte à aucune banque et ne gère pas plusieurs utilisateurs authentifiés.
Utilisez Python 3.12 ou une version ultérieure avec le module standard sqlite3 disponible. Le paramètre de connexion autocommit=False nécessite Python 3.12. Créez un nouveau dossier d’exercice, ouvrez-y un terminal et sélectionnez l’environnement du projet. Vous n’avez pas de paquet supplémentaire à installer.
Le fichier ledger.db sera créé dans le dossier courant, pas nécessairement à côté du script si vous l’exécutez depuis un autre emplacement. L’option --db permet de choisir une autre base d’exercice ; ne désignez pas une base de travail ou un fichier personnel. Contrairement à une collecte par identifiant externe, chaque appel réussi à add ajoute une nouvelle dépense. Répéter volontairement la même commande crée donc une ligne supplémentaire.
Enregistrer le programme complet
Enregistrez ce fichier en UTF-8 sous le nom ledger.py :
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 convertit l’entrée en entier et contrôle les bornes. date_value vérifie une date existante, puis compare son écriture avec isoformat() pour imposer précisément YYYY-MM-DD. Une forme compacte que fromisoformat sait lire ne suffit donc pas à être acceptée par cette interface. La référence des dates Python explique la conversion ; le second contrôle appartient à notre programme.
main choisit la sous-commande et gère la durée de vie de la connexion. Avant une commande autre que init, il exige un fichier de base existant. La catégorie est contrôlée, puis ses espaces extérieurs sont supprimés au moment de l’insertion. Les valeurs SQL utilisent des paramètres ?, comme dans le cours sur le stockage SQLite. Le SQL renforce les règles de montant et de catégorie ; la validation détaillée de la date est ici assurée par Python.
Initialiser, ajouter et consulter les dépenses
Dans le terminal du dossier d’exercice, lancez ces commandes dans l’ordre, une seule fois chacune pour les trois ajouts :
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 affiche initialized ; chaque add affiche added. La commande list imprime l’identifiant numérique, la date, la catégorie et le montant, en triant par date puis par identifiant. Dans cette base neuve, vous devez voir trois lignes avec les identifiants 1, 2 et 3. La commande summary produit :
food 11500
transport 2500
Les résultats restent enregistrés entre deux processus : fermez la commande puis relancez python ledger.py list pour les retrouver. init ne vide pas une table existante. Pour recommencer depuis zéro, conservez la première base et utilisez un nouveau fichier avec python ledger.py --db autre-registre.db init. Placez cette option globale avant la sous-commande, puis utilisez ce même chemin pour ses ajouts et ses consultations. Si le chemin contient des espaces, encadrez-le de guillemets.
La référence des sous-commandes argparse décrit leur organisation. python ledger.py --help présente les commandes et l’option globale ; python ledger.py add --help précise les arguments de l’ajout. Un dossier parent inexistant n’est pas créé automatiquement pour une base choisie par chemin.
Vérifier les rejets et exécuter le contrôle d’intégration
- Essayez
python ledger.py add 2026-02-30 food 8000: le jour n’existe pas et l’analyse des arguments doit échouer avant l’insertion. - Essayez le montant
-1, puis1000000000001; les deux bornes doivent être refusées. Essayez aussi une catégorie constituée d’espaces, par exemplepython ledger.py add 2026-10-17 " " 8000. - Après chaque erreur, consultez immédiatement
$LASTEXITCODEdans PowerShell ouecho $?dans un shell macOS/Ubuntu, puis exécutezlistetsummary. Les entrées refusées ne doivent pas augmenter le nombre de lignes ni changer les sommes. - Dans une autre base neuve, reproduisez les trois ajouts initiaux et comparez les totaux. Un simple nouveau
initsur la première base ne remplace pas cette remise à zéro.
Pour automatiser une partie de ces vérifications, enregistrez le fichier complet check_ledger.py à côté de 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")
Exécutez python check_ledger.py depuis ce dossier, avec le même interpréteur et sans l’option -O. Les expressions assert de cet exercice seraient supprimées en mode optimisé ; certaines commandes à vérifier ne seraient alors même plus exécutées. Le contrôle utilise une base dans un dossier temporaire, et ne réinitialise pas votre ledger.db.
Le résultat attendu est :
integration checks passed
Ce contrôle vérifie un ajout valide, trois rejets, le total et le nombre de lignes. Il ne couvre pas toutes les catégories vides, les erreurs de disque, la concurrence ou les migrations ; complétez-le selon les fonctions ajoutées, en suivant les principes du cours sur les tests et les cas limites. Les assert sont utilisés ici pour les vérifications pédagogiques ; une validation métier indispensable doit rester explicite dans l’application.
Comprendre les garanties et faire évoluer la structure
Le bloc de connexion utilisé comme gestionnaire de contexte valide ou annule la transaction, tandis que finally ferme la connexion. La documentation du contexte de connexion SQLite distingue ces deux responsabilités. Chaque processus de cette interface traite une commande ; les trois ajouts de l’exemple ne forment pas ensemble une seule transaction.
summary lit les lignes progressivement et additionne des entiers Python par catégorie. Cela évite de dépendre de l’accumulateur entier limité du SUM de SQLite ; la référence des fonctions d’agrégation SQLite détaille son dépassement possible et le résultat flottant de total(). Le dictionnaire des sommes occupe cependant de la mémoire proportionnellement au nombre de catégories.
Une table déjà présente n’est pas modifiée par CREATE TABLE IF NOT EXISTS. Une évolution du schéma, la sauvegarde, la restauration et les utilisateurs concurrents nécessitent une conception supplémentaire. Pour de vraies données personnelles, choisissez délibérément les droits du fichier, les sauvegardes et le lieu de partage.
Si le programme grandit, séparez l’analyse des arguments, les règles d’entrée, l’accès aux données et les calculs en modules. Conservez une entrée main protégée : importer un module ne doit pas créer une base ou lancer une interaction. Utilisez les tests pour préserver à la fois les résultats valides, les refus et le sens d’une réexécution pendant cette évolution.




