Chapitre 15 — Projet final : mener un projet de A à Z¶
⏱️ Durée estimée : 15 à 30 heures, réparties sur plusieurs semaines (le projet fil rouge : 6 à 10 heures ; ton projet personnel : le reste)
📋 Prérequis : avoir terminé le Chapitre 14 — Les spécialisations, et en particulier maîtriser les outils du Chapitre 12 (venv, Git, GitHub) et du Chapitre 13 (pytest, ruff, structure de projet).
🎯 Objectifs — À la fin de ce chapitre, tu sauras…
- choisir une idée de projet à ta mesure et rédiger son cahier des charges ;
- découper un projet en étapes et définir un MVP (produit minimum viable) ;
- organiser le code en couches (modèle, logique, stockage, interface) faciles à tester ;
- faire vivre un projet avec Git, des tests et un README ;
- publier ton projet sur GitHub, avec une version numérotée et des tests automatiques ;
- évaluer ton propre travail avec une grille et savoir quoi améliorer.
Cours¶
Tu as appris énormément de choses depuis le chapitre 00 : la syntaxe, les structures de données, les fonctions, les fichiers, les classes, les tests, Git… Mais il y a une marche entre « savoir faire des exercices » et « savoir construire un programme ». Les exercices ont un énoncé clair, une taille raisonnable et une solution. Un vrai projet, non : c'est toi qui décides quoi faire, dans quel ordre, et quand c'est « fini ».
Ce chapitre t'apprend à franchir cette marche. Il est en trois parties :
- la méthode : les huit étapes d'un projet, de l'idée à la publication (sections 1 à 9) ;
- un projet fil rouge entièrement guidé : un gestionnaire de budget personnel en ligne de commande, construit pas à pas (section 11) ;
- ton projet à toi, avec une liste d'idées (section 10), des jalons (exercices) et une grille d'auto-évaluation (section 12).
1. Pourquoi un projet personnel ?¶
Apprendre la programmation sans projet, c'est comme apprendre la cuisine en lisant des recettes sans jamais cuisiner : tu connais les techniques, mais tu ne sais pas les enchaîner, gérer les imprévus, ni goûter en cours de route.
Un projet personnel t'apporte :
- 🧩 L'assemblage : tu combines tout ce que tu as appris, et tu découvres comment les morceaux s'emboîtent.
- 🔥 La motivation : tu construis quelque chose qui te sert ou te plaît.
- 🐛 Les vrais problèmes : bugs bizarres, décisions de conception, documentation à chercher. C'est là qu'on progresse le plus.
- 🎓 Une vitrine : un projet propre sur GitHub, avec tests et README, vaut mieux que n'importe quelle ligne « Python » sur un CV.
💡 Un projet terminé et modeste vaut infiniment plus qu'un projet ambitieux abandonné à 30 %.
2. Étape 1 — Choisir une idée¶
2.1 Les bons critères¶
Une bonne idée de premier projet coche ces cases :
| Critère | Question à te poser |
|---|---|
| Utile ou amusant pour toi | Est-ce que je l'utiliserais moi-même ? Est-ce que j'ai envie de le montrer ? |
| Petit | Puis-je en faire une première version utilisable en une ou deux semaines ? |
| Dans tes cordes | Est-ce que je connais déjà 80 % de ce qu'il faut ? (Les 20 % restants, c'est l'apprentissage.) |
| Extensible | Une fois la base finie, puis-je ajouter des fonctionnalités petit à petit ? |
| Sans dépendance bloquante | Pas besoin d'un compte payant, d'un matériel spécial ou de données introuvables ? |
2.2 Le piège de l'ambition¶
« Je vais faire un clone de Minecraft / un réseau social / une IA qui prédit la bourse. » 🙃
Ces idées ne sont pas mauvaises… pour plus tard. Elles demandent des mois, voire des années, à des équipes entières. Commence par un projet dix fois plus petit que ce que tu imagines. Tu pourras toujours l'agrandir.
🎯 Astuce : décris ton projet en une phrase de moins de 20 mots. « Un programme en ligne de commande qui enregistre mes dépenses et me montre où part mon argent chaque mois. » Si tu n'y arrives pas, l'idée est encore floue.
3. Étape 2 — Rédiger un cahier des charges¶
Le cahier des charges décrit ce que fait le programme (pas comment). Il tient sur une page. Il te sert de boussole : quand tu ne sais plus quoi faire, tu le relis.
3.1 Les histoires utilisateur¶
Une façon simple et efficace de décrire les fonctionnalités : les histoires utilisateur (user stories), au format :
En tant que [qui], je veux [quoi], afin de [pourquoi].
Exemples pour un gestionnaire de budget :
- En tant qu'utilisateur, je veux ajouter une dépense avec un montant et une catégorie, afin de garder une trace de mes achats.
- En tant qu'utilisateur, je veux voir le bilan d'un mois, afin de savoir si j'ai dépensé plus que je n'ai gagné.
- En tant qu'utilisateur, je veux exporter mes données en CSV, afin de les ouvrir dans un tableur.
Le « afin de » est important : il t'oblige à vérifier que la fonctionnalité sert vraiment à quelque chose.
3.2 Prioriser : la méthode MoSCoW¶
Tu auras toujours plus d'idées que de temps. Classe chaque fonctionnalité :
| Priorité | Signification | Exemple (budget) |
|---|---|---|
| Must (indispensable) | sans elle, le programme n'a pas de sens | ajouter, lister, bilan, sauvegarde |
| Should (important) | très utile, mais on peut s'en passer au début | supprimer, filtrer par mois, export CSV |
| Could (bonus) | agréable si on a le temps | graphique, plafonds d'alerte |
| Won't (pas cette fois) | explicitement écarté pour cette version | synchronisation bancaire, application mobile |
La colonne Won't est précieuse : écrire noir sur blanc ce que tu ne feras pas te protège de la dispersion.
3.3 Les contraintes et les critères de réussite¶
Termine le cahier des charges par :
- les contraintes techniques : version de Python, bibliothèques autorisées, format de stockage, interface (ligne de commande, web, fenêtre…) ;
- les critères de réussite : comment sais-tu que c'est fini ? Par exemple : « toutes les fonctionnalités Must fonctionnent, sont testées, et un ami peut installer le programme en suivant le README ».
4. Étape 3 — Découper et définir un MVP¶
4.1 Le MVP¶
Le MVP (Minimum Viable Product, produit minimum viable) est la plus petite version qui fonctionne de bout en bout et apporte déjà de la valeur.
L'analogie classique : si l'objectif est de construire une voiture, ne construis pas d'abord une roue, puis un châssis, puis un moteur (rien n'est utilisable avant la fin). Construis d'abord une trottinette, puis un vélo, puis une moto, puis la voiture. À chaque étape, tu as quelque chose qui roule.
❌ roue → essieu → châssis → carrosserie → 🚗 (inutilisable jusqu'à la fin)
✅ 🛴 → 🚲 → 🏍️ → 🚗 (utilisable à chaque étape)
Pour le budget : le MVP, c'est « ajouter une dépense, la sauvegarder, l'afficher ». Pas de catégories élaborées, pas de graphique. Mais ça marche.
4.2 Les jalons¶
Découpe ensuite le projet en jalons (milestones) : de petites étapes de quelques heures, chacune testable et committable. Un bon jalon :
- se termine par quelque chose qui fonctionne (et des tests qui passent) ;
- peut se décrire en une phrase ;
- dure entre une et quatre heures.
Exemple de découpage :
| Jalon | Contenu | Résultat vérifiable |
|---|---|---|
| 0 | Mise en place : dossier, venv, Git, structure, pyproject.toml |
pytest tourne (0 test) |
| 1 | Classe Transaction |
tests du modèle verts |
| 2 | Classe Budget et ses calculs |
tests des calculs verts |
| 3 | Sauvegarde/chargement JSON | les données survivent à un redémarrage |
| 4 | Interface en ligne de commande | MVP utilisable 🎉 |
| 5 | Export/import CSV | ouverture dans un tableur |
| 6 | README, publication | un ami installe le projet |
Note l'ordre : on construit de l'intérieur vers l'extérieur (d'abord les données et la logique, faciles à tester ; l'interface en dernier).
💡 Note ta liste de jalons dans un fichier
TODO.mdou dans les Issues de GitHub. Cocher une case est étonnamment motivant.
5. Étape 4 — Structurer le code¶
5.1 Séparer les responsabilités¶
Le principe le plus important pour qu'un projet reste compréhensible : chaque morceau de code a une seule responsabilité. On organise généralement le code en couches :
┌─────────────────────────────────────────────┐
│ INTERFACE (cli.py, app web, fenêtre…) │ ← parle à l'humain : input/print, argparse
├─────────────────────────────────────────────┤
│ LOGIQUE (budget.py) │ ← les règles et les calculs
├─────────────────────────────────────────────┤
│ MODÈLE (modeles.py) │ ← les objets de base (Transaction)
├─────────────────────────────────────────────┤
│ STOCKAGE (stockage.py) │ ← lire/écrire fichiers JSON, CSV…
└─────────────────────────────────────────────┘
Règle d'or : la logique ne fait jamais print ni input. Elle reçoit des paramètres et renvoie des résultats. Avantages :
- elle se teste facilement (pas de clavier à simuler) ;
- tu peux changer d'interface (ligne de commande → site web) sans toucher à la logique. C'est exactement ce que tu feras à l'exercice 7 !
5.2 L'arborescence¶
On reprend la structure du chapitre 13 :
budget-cli/
├── .gitignore
├── README.md
├── pyproject.toml
├── src/
│ └── budget/
│ ├── __init__.py
│ ├── __main__.py ← permet « python -m budget »
│ ├── modeles.py
│ ├── budget.py
│ ├── stockage.py
│ └── cli.py
└── tests/
├── conftest.py ← fixtures partagées
├── test_modeles.py
├── test_budget.py
├── test_stockage.py
└── test_cli.py
6. Étape 5 — Git au quotidien¶
Quelques habitudes qui changent tout (chapitre 12) :
git initdès la première minute, avec le.gitignore.- Un commit par petite étape qui fonctionne, avec un message clair :
Ajoute le calcul du solde par mois. - Une branche par fonctionnalité :
git switch -c export-csv, puis fusion dansmainquand c'est terminé et testé.maindoit toujours fonctionner. - Pousse régulièrement sur GitHub : c'est aussi ta sauvegarde.
- Étiquette les versions avec un tag :
git tag -a v1.0.0 -m "Première version stable".
Le cycle d'une fonctionnalité :
git switch -c plafonds # 1. nouvelle branche
# … écrire les tests, puis le code, plusieurs commits …
ruff check . && ruff format . && pytest # 2. tout est vert ?
git switch main # 3. retour sur main
git merge plafonds # 4. fusion
git push # 5. publication
git branch -d plafonds # 6. ménage
7. Étape 6 — Tester au fil de l'eau¶
Ne garde pas les tests « pour la fin » : à la fin, tu n'auras plus envie. Écris-les en même temps que le code, voire avant (TDD, chapitre 13).
Que tester en priorité ?
- La logique (calculs, règles) : c'est là que se cachent les bugs les plus sournois, et c'est le plus facile à tester.
- Le stockage : un aller-retour « sauvegarder puis recharger » doit redonner exactement les mêmes données. Utilise
tmp_path. - Les cas d'erreur : montant nul, fichier corrompu, identifiant inconnu…
- L'interface : quelques tests « de bout en bout » qui lancent une commande et vérifient l'affichage (fixture
capsysde pytest).
Avant chaque commit : ruff check ., ruff format ., pytest. Trois commandes, dix secondes, zéro mauvaise surprise.
8. Étape 7 — Documenter avec un README¶
Le README est la première chose que quelqu'un voit de ton projet (y compris toi dans six mois). Il doit répondre en moins d'une minute à : c'est quoi ? comment l'installer ? comment l'utiliser ?
Sections recommandées :
- Titre et description en une ou deux phrases ;
- Fonctionnalités (liste à puces) ;
- Aperçu : un exemple de sortie du terminal ou une capture d'écran ;
- Installation : les commandes exactes, copiables ;
- Utilisation : les commandes principales avec un exemple ;
- Développement : comment lancer les tests ;
- Feuille de route (idées futures), licence, auteur.
Les docstrings (chapitre 13) documentent le code ; le README documente le projet. Les deux sont nécessaires.
9. Étape 8 — Publier¶
« Publier » peut vouloir dire plusieurs choses, de la plus simple à la plus avancée :
- Pousser sur GitHub (dépôt public) avec un README soigné. C'est le minimum.
- Créer une version (release) : un tag Git (
v1.0.0) et, sur GitHub, Releases → Draft a new release avec une description des nouveautés. - Rendre le projet installable : grâce au
pyproject.toml, n'importe qui peut l'installer directement depuis GitHub :
- Automatiser les tests avec GitHub Actions (intégration continue) : à chaque
push, GitHub lance ruff et pytest et affiche ✅ ou ❌. - Aller plus loin : publier sur PyPI (pour
pip install budget-cli), déployer une application web, partager un jeu sur itch.io…
Numérotation des versions : la convention la plus répandue est le versionnage sémantique, MAJEUR.MINEUR.CORRECTIF :
1.0.0 → 1.0.1: correction de bug ;1.0.1 → 1.1.0: nouvelle fonctionnalité compatible ;1.1.0 → 2.0.0: changement qui casse l'existant (ex. format de fichier différent).
🔐 Avant de publier, vérifie qu'aucun secret ne traîne dans l'historique (mots de passe, clés d'API, fichier
.env) et que tes données personnelles (ton vrai budget !) ne sont pas dans le dépôt.
Quand tu es bloqué¶
Ça arrivera, et c'est normal. Méthode, dans l'ordre :
- Lis le message d'erreur en entier, en partant du bas du traceback (chapitre 08).
- Reproduis le problème dans le plus petit cas possible (idéalement, un test qui échoue).
- Affiche ou débogue :
printdes valeurs, ou point d'arrêt dans VS Code. - Explique le problème à voix haute (à un canard en plastique, c'est une vraie technique : le rubber duck debugging). Souvent, la solution apparaît en formulant.
- Cherche : documentation officielle, recherche web avec le message d'erreur exact, forums.
- Demande de l'aide (forum, ami, assistant IA) en donnant : ce que tu veux faire, le code minimal, l'erreur complète, ce que tu as déjà essayé. Et comprends la réponse avant de la copier.
- Fais une pause. Sérieusement. La solution vient souvent sous la douche ou en marchant.
10. Idées de projets¶
Voici 15 idées classées par niveau et par domaine. Chacune peut commencer par un MVP très simple.
| # | Niveau | Domaine | Projet | MVP | Pour aller plus loin |
|---|---|---|---|---|---|
| 1 | ⭐ | Jeu | Pendu en terminal | deviner un mot tiré d'une liste | dessin ASCII, catégories, scores en JSON |
| 2 | ⭐ | Outil | Générateur de mots de passe | longueur et types de caractères choisis | évaluation de robustesse, phrases de passe, copie dans le presse-papiers |
| 3 | ⭐ | Apprentissage | Quiz de vocabulaire | questions tirées d'un fichier JSON | révision espacée, statistiques, plusieurs langues |
| 4 | ⭐ | Outil | Carnet de contacts | ajouter, chercher, supprimer (JSON) | import/export CSV, anniversaires du mois |
| 5 | ⭐⭐ | Automatisation | Rangement de Téléchargements | tri par extension (chapitre 14) | règles configurables, journal, planification |
| 6 | ⭐⭐ | Outil | Gestionnaire de tâches en CLI | ajouter, cocher, lister | priorités, échéances, sous-commandes argparse |
| 7 | ⭐⭐ | Data | Analyse de tes dépenses bancaires | lire l'export CSV de ta banque | catégorisation automatique, graphiques mensuels |
| 8 | ⭐⭐ | Jeu | Snake avec pygame | serpent qui grandit, collisions | niveaux, obstacles, meilleur score |
| 9 | ⭐⭐ | Web | Carnet de recettes avec Flask | liste et fiche de recettes | recherche par ingrédient, formulaire d'ajout, SQLite |
| 10 | ⭐⭐ | Data | Tableau de bord de données ouvertes | un CSV de data.gouv.fr, 3 graphiques | filtres interactifs avec Streamlit |
| 11 | ⭐⭐⭐ | Web | API de bibliothèque avec FastAPI | livres et emprunts | base de données, authentification, tests complets |
| 12 | ⭐⭐⭐ | Automatisation | Veille automatique | récupère des données d'une API publique chaque jour | alertes par e-mail, historique, graphique d'évolution |
| 13 | ⭐⭐⭐ | IA | Détecteur de spam | classification de SMS avec scikit-learn | comparaison de modèles, petite interface web |
| 14 | ⭐⭐⭐ | Jeu | Jeu de plateforme 2D | personnage qui saute sur des plateformes | gravité, niveaux en fichiers texte, ennemis, sons |
| 15 | ⭐⭐⭐ | Outil | Gestionnaire de budget complet | le projet fil rouge ci-dessous | plafonds, graphiques, API web, application |
🧭 Le meilleur projet est souvent celui qui résout ton problème : automatiser une corvée de ton travail, suivre tes entraînements sportifs, gérer la collection de jeux de ta famille, aider un proche dans son association…
11. Projet fil rouge : un gestionnaire de budget en ligne de commande¶
Nous allons appliquer toute la méthode à un vrai projet, de la page blanche à la publication. Suis les étapes dans l'ordre et tape le code toi-même (ne te contente pas de copier-coller : c'est en tapant qu'on remarque les détails).
11.1 Le cahier des charges¶
Budget CLI — Un programme en ligne de commande qui enregistre mes dépenses et revenus et me montre où part mon argent chaque mois.
Histoires utilisateur et priorités :
| Priorité | Histoire utilisateur |
|---|---|
| Must | En tant qu'utilisateur, je veux ajouter une dépense ou un revenu (montant, catégorie, description facultative, date par défaut aujourd'hui), afin de garder une trace de mes opérations. |
| Must | Je veux lister mes transactions, triées par date, afin de les relire. |
| Must | Je veux un bilan (revenus, dépenses, solde, dépenses par catégorie), afin de savoir où part mon argent. |
| Must | Je veux que mes données soient sauvegardées automatiquement entre deux utilisations. |
| Should | Je veux filtrer par mois et par catégorie, afin de me concentrer sur une période. |
| Should | Je veux supprimer une transaction, afin de corriger une erreur de saisie. |
| Should | Je veux exporter et importer en CSV, afin d'utiliser un tableur ou les relevés de ma banque. |
| Could | Je veux définir des plafonds par catégorie et être alerté, afin de ne pas trop dépenser. (exercice 3) |
| Could | Je veux un graphique des dépenses. (exercice 4) |
| Won't | Connexion à la banque, multi-utilisateurs, multi-devises, application mobile. |
Contraintes techniques :
- Python 3.12+, uniquement la bibliothèque standard pour le cœur (pas de dépendance obligatoire) ;
- données en JSON dans un fichier de l'utilisateur, export/import en CSV ;
- interface en ligne de commande avec argparse et des sous-commandes (
budget ajouter …,budget bilan …) ; - code organisé en couches, testé avec pytest, propre selon ruff.
Critères de réussite : toutes les fonctionnalités Must et Should fonctionnent et sont testées ; ruff ne signale rien ; le projet est sur GitHub et un ami peut l'installer en suivant le README.
11.2 La conception¶
Avant de coder, on réfléchit aux objets et à leurs responsabilités :
┌───────────────────────┐
terminal ───────▶ │ cli.py │ argparse, print
│ cmd_ajouter, … │
└──────────┬────────────┘
│ utilise
┌──────────────────┼──────────────────────┐
▼ ▼ ▼
┌───────────────────┐ ┌─────────────────────┐ ┌──────────────────────┐
│ modeles.py │ │ budget.py │ │ stockage.py │
│ Transaction │◀│ Budget │ │ charger/sauvegarder │
│ (dataclass) │ │ (liste + calculs) │ │ JSON, export CSV │
└───────────────────┘ └─────────────────────┘ └──────────────────────┘
Décisions de conception (et leurs raisons) :
- Un seul champ
montantsigné : négatif pour une dépense, positif pour un revenu. Le solde est alors une simple somme. - Un identifiant court (6 caractères) pour chaque transaction, afin de pouvoir la supprimer sans ambiguïté.
Transactionest unedataclass(chapitre 11) : on obtient__init__,__repr__et__eq__gratuitement (le__eq__sera très pratique dans les tests).Budget.filtrerrenvoie un nouveauBudget: on peut enchaîner filtre puis calcul (budget.filtrer(mois="2026-03").solde) sans modifier l'original.- Le fichier de données est configurable par une variable d'environnement (
BUDGET_FICHIER) : indispensable pour que les tests n'écrasent pas tes vraies données.
11.3 Étape 0 : mise en place¶
mkdir budget-cli && cd budget-cli
python3 -m venv .venv # Windows : py -m venv .venv
source .venv/bin/activate # Windows : .venv\Scripts\Activate.ps1
mkdir -p src/budget tests # Windows : mkdir src\budget, tests
git init
Crée le .gitignore :
Puis le pyproject.toml :
[project]
name = "budget-cli"
version = "0.1.0"
description = "Gestionnaire de budget personnel en ligne de commande"
readme = "README.md"
requires-python = ">=3.12"
dependencies = []
[project.optional-dependencies]
dev = ["pytest", "ruff"]
[project.scripts]
budget = "budget.cli:main"
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
[tool.ruff]
line-length = 88
[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP", "B"]
La nouveauté : la section [project.scripts]. Elle crée une vraie commande budget dans ton venv, qui appelle la fonction main du module budget.cli. Plus besoin de taper python quelque_chose.py !
src/budget/__init__.py :
Un README.md provisoire (# Budget CLI), puis :
pip install -e ".[dev]"
pytest # "no tests ran" : normal, il n'y a pas encore de test
git add .
git commit -m "Mise en place du projet"
11.4 Étape 1 : le modèle Transaction¶
src/budget/modeles.py :
"""Les objets métier : une transaction (dépense ou revenu)."""
from dataclasses import asdict, dataclass, field
from datetime import date
from uuid import uuid4
def nouvel_identifiant() -> str:
"""Renvoie un identifiant court et unique, par exemple 'a3f9c2'."""
return uuid4().hex[:6]
@dataclass
class Transaction:
"""Une opération : montant positif = revenu, montant négatif = dépense."""
montant: float
categorie: str
description: str = ""
jour: date = field(default_factory=date.today)
id: str = field(default_factory=nouvel_identifiant)
def __post_init__(self) -> None:
if self.montant == 0:
raise ValueError("Le montant ne peut pas être nul.")
self.categorie = self.categorie.strip().lower()
if not self.categorie:
raise ValueError("La catégorie est obligatoire.")
self.montant = round(self.montant, 2)
@property
def est_depense(self) -> bool:
return self.montant < 0
def vers_dict(self) -> dict:
"""Convertit la transaction en dictionnaire sérialisable en JSON."""
donnees = asdict(self)
donnees["jour"] = self.jour.isoformat()
return donnees
@classmethod
def depuis_dict(cls, donnees: dict) -> "Transaction":
"""Recrée une transaction à partir d'un dictionnaire (lu en JSON ou CSV)."""
return cls(
montant=float(donnees["montant"]),
categorie=donnees["categorie"],
description=donnees.get("description") or "",
jour=date.fromisoformat(donnees["jour"]),
id=donnees.get("id") or nouvel_identifiant(),
)
def __str__(self) -> str:
signe = "-" if self.est_depense else "+"
return (
f"{self.id} {self.jour:%d/%m/%Y} {signe}{abs(self.montant):>9.2f} € "
f"{self.categorie:<12} {self.description}"
)
Points à comprendre :
field(default_factory=date.today): la date par défaut est calculée à chaque création (et non une seule fois au chargement du module, ce qui figerait la date). Même chose pour l'identifiant.__post_init__est appelée automatiquement par la dataclass juste après__init__: c'est l'endroit idéal pour valider et normaliser les données (catégorie en minuscules, montant arrondi au centime). UneTransactioninvalide ne peut donc pas exister.uuid4().hex[:6]: un identifiant aléatoire de 6 caractères hexadécimaux (16 millions de possibilités, largement assez pour un budget personnel).vers_dict/depuis_dict: la conversion vers et depuis les dictionnaires, pour JSON et CSV. JSON ne connaît pas les dates : on les écrit au format ISO"2026-03-01".depuis_dictest une méthode de classe (@classmethod) : elle fabrique une nouvelle instance.donnees.get("description") or "": fonctionne si la clé est absente ou vide (Nonedans un CSV incomplet).
Les fixtures partagées, dans tests/conftest.py :
"""Fixtures partagées par tous les fichiers de tests."""
from datetime import date
import pytest
from budget.budget import Budget
from budget.modeles import Transaction
@pytest.fixture
def budget_exemple() -> Budget:
"""Un petit budget sur deux mois, avec des identifiants connus."""
return Budget(
[
Transaction(2000, "salaire", "Salaire janvier", date(2026, 1, 1), "t1"),
Transaction(-700, "loyer", "", date(2026, 1, 3), "t2"),
Transaction(-85.40, "courses", "Supermarché", date(2026, 1, 10), "t3"),
Transaction(-32.50, "courses", "Marché", date(2026, 1, 24), "t4"),
Transaction(2000, "salaire", "Salaire février", date(2026, 2, 1), "t5"),
Transaction(-700, "loyer", "", date(2026, 2, 3), "t6"),
Transaction(-60, "loisirs", "Concert", date(2026, 2, 14), "t7"),
]
)
@pytest.fixture
def fichier_donnees(tmp_path, monkeypatch):
"""Redirige le programme vers un fichier JSON temporaire."""
chemin = tmp_path / "transactions.json"
monkeypatch.setenv("BUDGET_FICHIER", str(chemin))
return chemin
(La fixture budget_exemple utilise la classe Budget de l'étape 2 : si tu suis l'ordre strictement, ajoute-la à ce moment-là.) monkeypatch.setenv définit une variable d'environnement pour la durée d'un test seulement.
tests/test_modeles.py :
from datetime import date
import pytest
from budget.modeles import Transaction
def test_creation_depense():
t = Transaction(-12.5, "Courses", "Pain", date(2026, 3, 1))
assert t.est_depense
assert t.categorie == "courses" # normalisée en minuscules
assert len(t.id) == 6
def test_revenu_n_est_pas_une_depense():
assert not Transaction(100, "salaire").est_depense
def test_date_par_defaut_aujourd_hui():
assert Transaction(10, "divers").jour == date.today()
def test_montant_arrondi_au_centime():
assert Transaction(-10.456, "courses").montant == -10.46
@pytest.mark.parametrize(
"montant, categorie",
[(0, "courses"), (10, ""), (10, " ")],
)
def test_transaction_invalide(montant, categorie):
with pytest.raises(ValueError):
Transaction(montant, categorie)
def test_identifiants_differents():
ids = {Transaction(1, "x").id for _ in range(100)}
assert len(ids) == 100
def test_aller_retour_dictionnaire():
originale = Transaction(-42.0, "loisirs", "Cinéma", date(2026, 5, 4), "abc123")
donnees = originale.vers_dict()
assert donnees["jour"] == "2026-05-04"
assert Transaction.depuis_dict(donnees) == originale
def test_affichage():
t = Transaction(-42, "loisirs", "Cinéma", date(2026, 5, 4), "abc123")
assert str(t) == "abc123 04/05/2026 - 42.00 € loisirs Cinéma"
11.5 Étape 2 : la logique Budget¶
src/budget/budget.py :
"""La classe Budget : une collection de transactions et des calculs dessus."""
from collections import defaultdict
from collections.abc import Iterator
from datetime import date
from budget.modeles import Transaction
class TransactionIntrouvableError(KeyError):
"""Levée quand aucun élément ne correspond à l'identifiant demandé."""
class Budget:
def __init__(self, transactions: list[Transaction] | None = None) -> None:
self.transactions: list[Transaction] = list(transactions or [])
def __len__(self) -> int:
return len(self.transactions)
def __iter__(self) -> Iterator[Transaction]:
return iter(self.transactions)
def ajouter(self, transaction: Transaction) -> None:
self.transactions.append(transaction)
def supprimer(self, identifiant: str) -> Transaction:
for transaction in self.transactions:
if transaction.id == identifiant:
self.transactions.remove(transaction)
return transaction
raise TransactionIntrouvableError(identifiant)
def filtrer(
self, mois: str | None = None, categorie: str | None = None
) -> "Budget":
"""Renvoie un nouveau Budget limité à un mois ('AAAA-MM') et/ou à une catégorie.
Les deux filtres sont facultatifs et peuvent être combinés.
"""
resultat = self.transactions
if mois:
resultat = [t for t in resultat if t.jour.strftime("%Y-%m") == mois]
if categorie:
resultat = [t for t in resultat if t.categorie == categorie.lower()]
return Budget(resultat)
def trier_par_date(self) -> list[Transaction]:
return sorted(self.transactions, key=lambda t: (t.jour, t.id))
@property
def total_revenus(self) -> float:
return round(sum(t.montant for t in self if not t.est_depense), 2)
@property
def total_depenses(self) -> float:
"""Total des dépenses, en valeur positive."""
return round(-sum(t.montant for t in self if t.est_depense), 2)
@property
def solde(self) -> float:
return round(self.total_revenus - self.total_depenses, 2)
def depenses_par_categorie(self) -> dict[str, float]:
"""Total des dépenses par catégorie, de la plus grosse à la plus petite."""
totaux: defaultdict[str, float] = defaultdict(float)
for transaction in self:
if transaction.est_depense:
totaux[transaction.categorie] += -transaction.montant
tries = sorted(totaux.items(), key=lambda paire: paire[1], reverse=True)
return {categorie: round(total, 2) for categorie, total in tries}
def solde_par_mois(self) -> dict[str, float]:
"""Solde de chaque mois, dans l'ordre chronologique."""
soldes: defaultdict[str, float] = defaultdict(float)
for transaction in self:
soldes[transaction.jour.strftime("%Y-%m")] += transaction.montant
return {mois: round(soldes[mois], 2) for mois in sorted(soldes)}
def premiere_date(self) -> date | None:
return min((t.jour for t in self), default=None)
Points à comprendre :
__len__et__iter__: grâce à ces méthodes spéciales, on peut écrirelen(budget)etfor t in budget:comme avec une liste.list(transactions or []): on fait une copie de la liste reçue. Sinon,filtrerpartagerait la même liste que l'original et unajoutersur le résultat filtré modifierait aussi l'original ! (Un test vérifie justement ce point.)TransactionIntrouvableErrorhérite deKeyError: c'est une erreur de « clé introuvable », avec un nom plus parlant (chapitre 08).round(..., 2)partout : lesfloatproduisent parfois des résultats comme117.89999999999999. On arrondit au centime les montants affichés et comparés.defaultdict(float)(chapitre 09) : une clé absente vaut automatiquement0.0, pratique pour accumuler.min(..., default=None): renvoieNoneau lieu de planter si le budget est vide.
tests/test_budget.py :
import pytest
from budget.budget import Budget, TransactionIntrouvableError
from budget.modeles import Transaction
def test_budget_vide():
budget = Budget()
assert len(budget) == 0
assert budget.solde == 0
assert budget.depenses_par_categorie() == {}
assert budget.premiere_date() is None
def test_totaux(budget_exemple):
assert budget_exemple.total_revenus == 4000
assert budget_exemple.total_depenses == pytest.approx(1577.90)
assert budget_exemple.solde == pytest.approx(2422.10)
def test_ajouter(budget_exemple):
budget_exemple.ajouter(Transaction(-10, "divers"))
assert len(budget_exemple) == 8
def test_supprimer(budget_exemple):
supprimee = budget_exemple.supprimer("t7")
assert supprimee.categorie == "loisirs"
assert len(budget_exemple) == 6
def test_supprimer_inconnue(budget_exemple):
with pytest.raises(TransactionIntrouvableError):
budget_exemple.supprimer("zzz")
@pytest.mark.parametrize(
"mois, categorie, nombre_attendu",
[
("2026-01", None, 4),
("2026-02", None, 3),
("2026-03", None, 0),
(None, "courses", 2),
(None, "COURSES", 2), # insensible à la casse
("2026-02", "loyer", 1),
(None, None, 7),
],
)
def test_filtrer(budget_exemple, mois, categorie, nombre_attendu):
assert len(budget_exemple.filtrer(mois=mois, categorie=categorie)) == nombre_attendu
def test_filtrer_ne_modifie_pas_l_original(budget_exemple):
budget_exemple.filtrer(mois="2026-01").ajouter(Transaction(1, "x"))
assert len(budget_exemple) == 7
def test_depenses_par_categorie_triees(budget_exemple):
assert budget_exemple.depenses_par_categorie() == {
"loyer": 1400,
"courses": 117.90,
"loisirs": 60,
}
def test_solde_par_mois(budget_exemple):
assert budget_exemple.solde_par_mois() == {"2026-01": 1182.10, "2026-02": 1240}
def test_trier_par_date(budget_exemple):
budget_exemple.ajouter(Transaction(5, "x", jour=budget_exemple.premiere_date()))
dates = [t.jour for t in budget_exemple.trier_par_date()]
assert dates == sorted(dates)
11.6 Étape 3 : le stockage JSON et CSV¶
src/budget/stockage.py :
"""Sauvegarde et chargement : JSON pour les données, CSV pour l'export/import."""
import csv
import json
from pathlib import Path
from budget.budget import Budget
from budget.modeles import Transaction
COLONNES_CSV = ["id", "jour", "montant", "categorie", "description"]
def sauvegarder_json(budget: Budget, chemin: Path) -> None:
donnees = [t.vers_dict() for t in budget.trier_par_date()]
chemin.parent.mkdir(parents=True, exist_ok=True)
texte = json.dumps(donnees, ensure_ascii=False, indent=2)
chemin.write_text(texte, encoding="utf-8")
def charger_json(chemin: Path) -> Budget:
"""Charge le budget ; renvoie un budget vide si le fichier n'existe pas encore."""
if not chemin.exists():
return Budget()
try:
donnees = json.loads(chemin.read_text(encoding="utf-8"))
except json.JSONDecodeError as erreur:
raise ValueError(f"Fichier de données corrompu : {chemin}") from erreur
return Budget([Transaction.depuis_dict(d) for d in donnees])
def exporter_csv(budget: Budget, chemin: Path) -> int:
"""Écrit les transactions dans un CSV. Renvoie le nombre de lignes écrites."""
with open(chemin, "w", newline="", encoding="utf-8") as fichier:
ecrivain = csv.DictWriter(fichier, fieldnames=COLONNES_CSV)
ecrivain.writeheader()
for transaction in budget.trier_par_date():
ecrivain.writerow(transaction.vers_dict())
return len(budget)
def importer_csv(chemin: Path) -> list[Transaction]:
"""Lit un CSV (colonnes jour, montant, categorie, description ; id facultatif)."""
with open(chemin, newline="", encoding="utf-8") as fichier:
lecteur = csv.DictReader(fichier)
transactions = []
for numero, ligne in enumerate(lecteur, start=2): # ligne 1 = en-tête
try:
ligne["montant"] = (ligne.get("montant") or "").replace(",", ".")
transactions.append(Transaction.depuis_dict(ligne))
except (KeyError, ValueError) as erreur:
raise ValueError(f"Ligne {numero} invalide : {erreur}") from erreur
return transactions
Points à comprendre :
ensure_ascii=Falsegarde les accents lisibles dans le fichier JSON (« Supermarché » au lieu deSupermarché) ;indent=2le rend lisible par un humain.chemin.parent.mkdir(parents=True, exist_ok=True)crée le dossier~/.budget/au premier lancement.- Un fichier JSON corrompu (modifié à la main et cassé) produit une
ValueErroravec un message clair, transformée plus haut en message pour l'utilisateur.from erreurgarde la cause d'origine pour le débogage. csv.DictWriter/csv.DictReader(chapitre 07) travaillent avec des dictionnaires : ils se combinent parfaitement avecvers_dict/depuis_dict.- L'import accepte la virgule décimale (
"-12,90", fréquent dans les exports bancaires français) et indique le numéro de la ligne fautive : un message d'erreur utile fait gagner beaucoup de temps.
tests/test_stockage.py :
import pytest
from budget.budget import Budget
from budget.stockage import charger_json, exporter_csv, importer_csv, sauvegarder_json
def test_charger_fichier_absent(tmp_path):
assert len(charger_json(tmp_path / "absent.json")) == 0
def test_aller_retour_json(budget_exemple, tmp_path):
chemin = tmp_path / "sous_dossier" / "donnees.json" # dossier créé automatiquement
sauvegarder_json(budget_exemple, chemin)
recharge = charger_json(chemin)
assert recharge.trier_par_date() == budget_exemple.trier_par_date()
def test_json_lisible_avec_accents(budget_exemple, tmp_path):
chemin = tmp_path / "donnees.json"
sauvegarder_json(budget_exemple, chemin)
assert "Supermarché" in chemin.read_text(encoding="utf-8")
def test_json_corrompu(tmp_path):
chemin = tmp_path / "donnees.json"
chemin.write_text("{pas du json", encoding="utf-8")
with pytest.raises(ValueError, match="corrompu"):
charger_json(chemin)
def test_aller_retour_csv(budget_exemple, tmp_path):
chemin = tmp_path / "export.csv"
assert exporter_csv(budget_exemple, chemin) == 7
importees = importer_csv(chemin)
assert Budget(importees).trier_par_date() == budget_exemple.trier_par_date()
def test_import_csv_sans_id_et_virgule_decimale(tmp_path):
chemin = tmp_path / "banque.csv"
chemin.write_text(
"jour,montant,categorie,description\n"
'2026-03-01,"-12,90",Courses,Boulangerie\n'
"2026-03-02,1500,salaire,\n",
encoding="utf-8",
)
transactions = importer_csv(chemin)
assert [t.montant for t in transactions] == [-12.90, 1500]
assert all(len(t.id) == 6 for t in transactions)
def test_import_csv_ligne_invalide(tmp_path):
chemin = tmp_path / "mauvais.csv"
chemin.write_text(
"jour,montant,categorie\n2026-03-01,-5,courses\n2026-13-45,-5,courses\n",
encoding="utf-8",
)
with pytest.raises(ValueError, match="Ligne 3"):
importer_csv(chemin)
def test_import_csv_colonne_manquante(tmp_path):
chemin = tmp_path / "incomplet.csv"
chemin.write_text("jour,categorie\n2026-03-01,courses\n", encoding="utf-8")
with pytest.raises(ValueError, match="Ligne 2"):
importer_csv(chemin)
Grâce au __eq__ de la dataclass, recharge.trier_par_date() == budget_exemple.trier_par_date() compare les deux listes transaction par transaction, champ par champ. Le test d'aller-retour est le test le plus important d'un module de stockage.
11.7 Étape 4 : l'interface en ligne de commande (le MVP !)¶
src/budget/cli.py :
"""Interface en ligne de commande : python -m budget <commande> [options]."""
import argparse
import os
import sys
from datetime import date
from pathlib import Path
from budget.budget import Budget, TransactionIntrouvableError
from budget.modeles import Transaction
from budget.stockage import charger_json, exporter_csv, importer_csv, sauvegarder_json
FICHIER_PAR_DEFAUT = Path.home() / ".budget" / "transactions.json"
def chemin_donnees() -> Path:
"""Le fichier de données ; la variable d'environnement BUDGET_FICHIER prime."""
return Path(os.environ.get("BUDGET_FICHIER", FICHIER_PAR_DEFAUT))
def date_valide(texte: str) -> date:
try:
return date.fromisoformat(texte)
except ValueError:
raise argparse.ArgumentTypeError(
f"date invalide : {texte!r} (format attendu : AAAA-MM-JJ)"
) from None
def barre(valeur: float, maximum: float, largeur: int = 20) -> str:
"""Petite barre horizontale en texte, proportionnelle à valeur / maximum."""
if maximum <= 0:
return ""
return "█" * round(largeur * valeur / maximum)
# --- Commandes ---------------------------------------------------------------
def cmd_ajouter(budget: Budget, args: argparse.Namespace) -> bool:
montant = -abs(args.montant) if args.type == "depense" else abs(args.montant)
transaction = Transaction(
montant=montant,
categorie=args.categorie,
description=args.description,
jour=args.date or date.today(),
)
budget.ajouter(transaction)
print(f"✅ Ajouté : {transaction}")
return True # le budget a changé : il faudra sauvegarder
def cmd_lister(budget: Budget, args: argparse.Namespace) -> bool:
selection = budget.filtrer(mois=args.mois, categorie=args.categorie)
if not len(selection):
print("Aucune transaction.")
return False
for transaction in selection.trier_par_date():
print(transaction)
print(f"--- {len(selection)} transaction(s), solde : {selection.solde:+.2f} €")
return False
def cmd_supprimer(budget: Budget, args: argparse.Namespace) -> bool:
try:
transaction = budget.supprimer(args.id)
except TransactionIntrouvableError:
print(f"❌ Aucune transaction avec l'identifiant {args.id}.", file=sys.stderr)
return False
print(f"🗑️ Supprimé : {transaction}")
return True
def cmd_bilan(budget: Budget, args: argparse.Namespace) -> bool:
selection = budget.filtrer(mois=args.mois)
titre = f"Bilan {args.mois}" if args.mois else "Bilan global"
print(f"=== {titre} ===")
print(f"Revenus : {selection.total_revenus:>10.2f} €")
print(f"Dépenses : {selection.total_depenses:>10.2f} €")
print(f"Solde : {selection.solde:>+10.2f} €")
par_categorie = selection.depenses_par_categorie()
if par_categorie:
print("\nDépenses par catégorie :")
plus_grosse = max(par_categorie.values())
for categorie, total in par_categorie.items():
part = 100 * total / selection.total_depenses
print(
f" {categorie:<12} {total:>9.2f} € {part:5.1f} % "
f"{barre(total, plus_grosse)}"
)
if not args.mois:
print("\nSolde par mois :")
for mois, solde in selection.solde_par_mois().items():
print(f" {mois} {solde:>+10.2f} €")
return False
def cmd_exporter(budget: Budget, args: argparse.Namespace) -> bool:
nombre = exporter_csv(budget, args.fichier)
print(f"📤 {nombre} transaction(s) exportée(s) dans {args.fichier}")
return False
def cmd_importer(budget: Budget, args: argparse.Namespace) -> bool:
try:
nouvelles = importer_csv(args.fichier)
except (OSError, ValueError) as erreur:
print(f"❌ Import impossible : {erreur}", file=sys.stderr)
return False
deja_presents = {t.id for t in budget}
ajoutees = [t for t in nouvelles if t.id not in deja_presents]
for transaction in ajoutees:
budget.ajouter(transaction)
ignorees = len(nouvelles) - len(ajoutees)
print(f"📥 {len(ajoutees)} transaction(s) importée(s), {ignorees} ignorée(s)")
return bool(ajoutees)
# --- Analyse des arguments ---------------------------------------------------
def creer_analyseur() -> argparse.ArgumentParser:
analyseur = argparse.ArgumentParser(
prog="budget", description="Gestionnaire de budget personnel."
)
sous = analyseur.add_subparsers(dest="commande", required=True)
p = sous.add_parser("ajouter", help="ajouter une dépense ou un revenu")
p.add_argument("type", choices=["depense", "revenu"])
p.add_argument("montant", type=float, help="montant en euros (ex. 12.50)")
p.add_argument("categorie", help="ex. courses, loyer, salaire")
p.add_argument("-d", "--description", default="")
p.add_argument("--date", type=date_valide, help="AAAA-MM-JJ (défaut : aujourd'hui)")
p.set_defaults(fonction=cmd_ajouter)
p = sous.add_parser("lister", help="afficher les transactions")
p.add_argument("--mois", help="AAAA-MM")
p.add_argument("--categorie")
p.set_defaults(fonction=cmd_lister)
p = sous.add_parser("supprimer", help="supprimer une transaction")
p.add_argument("id", help="identifiant affiché par 'lister'")
p.set_defaults(fonction=cmd_supprimer)
p = sous.add_parser("bilan", help="revenus, dépenses et répartition")
p.add_argument("--mois", help="AAAA-MM")
p.set_defaults(fonction=cmd_bilan)
p = sous.add_parser("exporter", help="exporter en CSV")
p.add_argument("fichier", type=Path)
p.set_defaults(fonction=cmd_exporter)
p = sous.add_parser("importer", help="importer depuis un CSV")
p.add_argument("fichier", type=Path)
p.set_defaults(fonction=cmd_importer)
return analyseur
def main(arguments: list[str] | None = None) -> int:
args = creer_analyseur().parse_args(arguments)
fichier = chemin_donnees()
try:
budget = charger_json(fichier)
except ValueError as erreur:
print(f"❌ {erreur}", file=sys.stderr)
return 1
if args.fonction(budget, args):
sauvegarder_json(budget, fichier)
return 0
if __name__ == "__main__":
sys.exit(main())
Et src/budget/__main__.py, qui permet aussi de lancer python -m budget :
"""Permet de lancer le programme avec : python -m budget"""
import sys
from budget.cli import main
sys.exit(main())
Points à comprendre :
- Les sous-commandes (
add_subparsers) : chaque commande (ajouter,lister…) a ses propres arguments, commegit add/git commit.set_defaults(fonction=cmd_ajouter)associe chaque sous-commande à la fonction qui la traite :mainn'a plus qu'à appelerargs.fonction(...), sans grande cascade deif/elif. - Toutes les commandes ont la même signature
(budget, args) -> boolet renvoientTruesi le budget a été modifié.mainne sauvegarde que dans ce cas : un simplelisterne réécrit pas le fichier. type=date_valide: argparse appelle notre fonction pour convertir et valider le texte ; en cas d'erreur, il affiche un message propre et s'arrête.-abs(args.montant): une dépense est toujours négative, même si l'utilisateur tape-30par erreur (il faut alors écrirebudget ajouter depense -- -30 loisirs, car argparse prendrait-30pour une option ;--signifie « fin des options »).- Les erreurs vont sur
sys.stderr(la sortie d'erreur) etmainrenvoie un code de sortie (0 = succès, 1 = erreur), comme tous les programmes en ligne de commande. main(arguments=None): sans argument, argparse lit la ligne de commande réelle ; dans les tests, on lui passe une liste. C'est ce qui rend la CLI testable.barre()dessine un petit histogramme en caractères█: un « graphique » sans aucune bibliothèque !
tests/test_cli.py :
import json
import pytest
from budget.cli import barre, main
def lancer(*arguments):
"""Lance le programme comme depuis le terminal, renvoie le code de sortie."""
return main(list(arguments))
def test_ajouter_puis_lister(fichier_donnees, capsys):
assert lancer("ajouter", "depense", "12.5", "courses", "-d", "Pain") == 0
assert lancer("ajouter", "revenu", "100", "cadeau", "--date", "2026-03-01") == 0
donnees = json.loads(fichier_donnees.read_text(encoding="utf-8"))
assert [d["montant"] for d in donnees] == [100, -12.5] # trié par date
capsys.readouterr() # vide ce qui a déjà été affiché
lancer("lister")
sortie = capsys.readouterr().out
assert "Pain" in sortie
assert "2 transaction(s), solde : +87.50 €" in sortie
def test_depense_toujours_negative(fichier_donnees):
lancer("ajouter", "depense", "--", "-30", "loisirs") # même si on tape -30
donnees = json.loads(fichier_donnees.read_text(encoding="utf-8"))
assert donnees[0]["montant"] == -30
def test_supprimer(fichier_donnees, capsys):
lancer("ajouter", "depense", "5", "divers")
identifiant = json.loads(fichier_donnees.read_text(encoding="utf-8"))[0]["id"]
lancer("supprimer", identifiant)
assert json.loads(fichier_donnees.read_text(encoding="utf-8")) == []
def test_supprimer_inconnu(fichier_donnees, capsys):
lancer("supprimer", "zzz")
assert "Aucune transaction" in capsys.readouterr().err
def test_bilan_mensuel(fichier_donnees, capsys):
lancer("ajouter", "revenu", "1000", "salaire", "--date", "2026-04-01")
lancer("ajouter", "depense", "300", "loyer", "--date", "2026-04-02")
lancer("ajouter", "depense", "100", "courses", "--date", "2026-04-05")
lancer("ajouter", "depense", "999", "courses", "--date", "2026-05-05")
capsys.readouterr()
lancer("bilan", "--mois", "2026-04")
sortie = capsys.readouterr().out
assert "Dépenses : 400.00 €" in sortie
assert "Solde : +600.00 €" in sortie
assert "loyer" in sortie and "75.0 %" in sortie
def test_export_import(fichier_donnees, tmp_path, capsys):
lancer("ajouter", "depense", "5", "divers")
export = tmp_path / "export.csv"
lancer("exporter", str(export))
lancer("importer", str(export)) # mêmes identifiants : rien n'est dupliqué
assert "0 transaction(s) importée(s), 1 ignorée(s)" in capsys.readouterr().out
assert len(json.loads(fichier_donnees.read_text(encoding="utf-8"))) == 1
def test_date_invalide(fichier_donnees):
with pytest.raises(SystemExit): # argparse quitte le programme
lancer("ajouter", "depense", "5", "divers", "--date", "31/12/2026")
def test_fichier_corrompu(fichier_donnees, capsys):
fichier_donnees.write_text("n'importe quoi", encoding="utf-8")
assert lancer("lister") == 1
assert "corrompu" in capsys.readouterr().err
@pytest.mark.parametrize(
"valeur, maximum, attendu",
[(10, 10, 20), (5, 10, 10), (0, 10, 0), (5, 0, 0)],
)
def test_barre(valeur, maximum, attendu):
assert len(barre(valeur, maximum)) == attendu
Deux fixtures intégrées de pytest à connaître :
capsyscapture ce que le programme affiche :capsys.readouterr().out(sortie normale) et.err(erreurs).monkeypatch(utilisée dans notre fixturefichier_donnees) modifie temporairement l'environnement ; ici, elle fait pointerBUDGET_FICHIERvers un dossier temporaire. Tes vraies données ne sont jamais touchées par les tests.
tests/test_budget.py ................ [ 34%]
tests/test_cli.py ............ [ 60%]
tests/test_modeles.py .......... [ 82%]
tests/test_stockage.py ........ [100%]
============================== 46 passed in 0.12s ==============================
ruff check . && ruff format --check .
git add . && git commit -m "Ajoute l'interface en ligne de commande (MVP)"
🎉 Le MVP est terminé. Toutes les fonctionnalités Must et Should fonctionnent, avec 46 tests.
11.8 Utilisation réelle¶
Grâce à pip install -e . et à la section [project.scripts], la commande budget existe dans ton venv :
budget --help
budget ajouter revenu 2100 salaire -d "Salaire mars" --date 2026-03-01
budget ajouter depense 750 loyer --date 2026-03-03
budget ajouter depense 64.20 courses -d "Supermarché" --date 2026-03-08
budget ajouter depense 23.90 courses -d "Marché" --date 2026-03-15
budget ajouter depense 45 loisirs -d "Cinéma + resto" --date 2026-03-21
budget ajouter depense 39.99 abonnements -d "Téléphone" --date 2026-03-05
budget lister --mois 2026-03
budget bilan --mois 2026-03
867152 01/03/2026 + 2100.00 € salaire Salaire mars
2ef131 03/03/2026 - 750.00 € loyer
00fcc5 05/03/2026 - 39.99 € abonnements Téléphone
d4e103 08/03/2026 - 64.20 € courses Supermarché
60aaaa 15/03/2026 - 23.90 € courses Marché
6c799d 21/03/2026 - 45.00 € loisirs Cinéma + resto
--- 6 transaction(s), solde : +1176.91 €
=== Bilan 2026-03 ===
Revenus : 2100.00 €
Dépenses : 923.09 €
Solde : +1176.91 €
Dépenses par catégorie :
loyer 750.00 € 81.2 % ████████████████████
courses 88.10 € 9.5 % ██
loisirs 45.00 € 4.9 % █
abonnements 39.99 € 4.3 % █
(Tes identifiants seront différents : ils sont tirés au hasard.)
Les données sont dans ~/.budget/transactions.json (dans ton dossier personnel). Pour essayer sans toucher à tes vraies données, change le fichier le temps d'une session :
Et pour exporter vers un tableur : budget exporter mars.csv.
11.9 Le README¶
README.md :
# 💶 Budget CLI
Gestionnaire de budget personnel en ligne de commande, écrit en Python.
Enregistre tes dépenses et revenus, et vois en un coup d'œil où part ton argent.
## Fonctionnalités
- Ajouter des dépenses et des revenus (catégorie, description, date)
- Lister et filtrer par mois ou par catégorie
- Bilan : revenus, dépenses, solde, répartition par catégorie
- Supprimer une transaction
- Export et import CSV (compatible tableur, virgule décimale acceptée)
- Aucune dépendance : uniquement la bibliothèque standard de Python
## Aperçu
```text
$ budget bilan --mois 2026-03
=== Bilan 2026-03 ===
Revenus : 2100.00 €
Dépenses : 923.09 €
Solde : +1176.91 €
Dépenses par catégorie :
loyer 750.00 € 81.2 % ████████████████████
courses 88.10 € 9.5 % ██
```
## Installation
Python 3.12 ou plus récent est nécessaire.
```bash
git clone https://github.com/TON-PSEUDO/budget-cli.git
cd budget-cli
python3 -m venv .venv
source .venv/bin/activate # Windows : .venv\Scripts\Activate.ps1
pip install -e .
```
## Utilisation
```bash
budget ajouter depense 12.50 courses -d "Boulangerie"
budget ajouter revenu 2100 salaire --date 2026-03-01
budget lister --mois 2026-03
budget bilan --mois 2026-03
budget supprimer <id>
budget exporter mes_comptes.csv
budget --help
```
Les données sont enregistrées dans `~/.budget/transactions.json`.
Pour utiliser un autre fichier, définis la variable d'environnement `BUDGET_FICHIER`.
## Développement
```bash
pip install -e ".[dev]"
pytest # lance les tests
ruff check . # vérifie le style
ruff format . # formate le code
```
## Feuille de route
- [ ] Plafonds de dépenses par catégorie avec alertes
- [ ] Graphique des dépenses
- [ ] API web
## Licence
MIT — Camille Martin
11.10 Publication¶
# 1. Crée un dépôt VIDE "budget-cli" sur GitHub, puis :
git remote add origin https://github.com/TON-PSEUDO/budget-cli.git
git push -u origin main
# 2. Étiquette la version
git tag -a v0.1.0 -m "Premier MVP"
git push origin v0.1.0
Sur GitHub, va dans Releases → Draft a new release, choisis le tag v0.1.0, rédige quelques lignes sur les fonctionnalités et publie.
Test de vérité : dans un autre dossier, sur un autre venv, installe ton projet directement depuis GitHub :
Si ça marche, ton projet est réellement publié : n'importe qui peut l'installer en une commande. 🚀
Les exercices de ce chapitre sont les jalons suivants de ce projet : recherche, couverture de tests, plafonds, graphique, intégration continue, version 1.0 et API web.
12. Grille d'auto-évaluation¶
Utilise cette grille pour évaluer le projet fil rouge et ton projet personnel. Pour chaque critère, attribue-toi un niveau de 1 à 4, honnêtement. L'objectif n'est pas d'avoir 4 partout du premier coup, mais de savoir quoi améliorer ensuite.
| Critère | 1 — Débutant | 2 — En progrès | 3 — Solide | 4 — Exemplaire |
|---|---|---|---|---|
| Fonctionnalités | le programme plante souvent | les cas simples fonctionnent | toutes les fonctionnalités Must marchent | Must + Should marchent, cas limites gérés |
| Organisation du code | tout dans un seul fichier | quelques fonctions | modules séparés (modèle, logique, interface) | couches claires, logique sans print/input, aucune duplication |
| Lisibilité | noms obscurs (x, tmp2) |
noms corrects, style irrégulier | PEP 8 respectée, ruff sans erreur |
docstrings, annotations de type, commentaires sur le « pourquoi » |
| Gestion des erreurs | traceback pour toute erreur de saisie | quelques try/except |
erreurs prévues avec messages clairs | exceptions personnalisées, codes de sortie, données jamais corrompues |
| Tests | aucun | quelques tests manuels | tests pytest de la logique | logique + stockage + interface, cas limites, fixtures, couverture > 80 % |
| Git | pas de Git, ou un seul commit | commits irréguliers | commits fréquents aux messages clairs, .gitignore |
branches par fonctionnalité, tags de version, historique lisible |
| Documentation | pas de README | README d'une ligne | README avec installation et utilisation | README complet avec aperçu, feuille de route, licence |
| Publication | uniquement en local | sur GitHub | sur GitHub, installable en suivant le README | release taguée, installable par pip, tests automatiques (CI) |
Comment l'utiliser :
- Score sur 32. En dessous de 16 : concentre-toi sur les fonctionnalités et l'organisation. De 16 à 24 : soigne les tests et la documentation. Au-dessus de 24 : bravo, ton projet est de niveau professionnel junior !
- Repère tes deux critères les plus faibles : ce sont tes prochains objectifs.
- Refais l'évaluation à chaque jalon. Voir ses notes monter est très motivant.
- Encore mieux : demande à quelqu'un d'autre d'installer ton projet en suivant uniquement le README, et observe où il bloque.
Pièges fréquents¶
1. Le projet trop ambitieux¶
Symptôme : après trois semaines, rien ne fonctionne encore de bout en bout ; tu perds la motivation et tu abandonnes.
Correction : réduis le périmètre jusqu'à pouvoir livrer un MVP en une à deux semaines. Remplis la colonne Won't du cahier des charges sans scrupule. Tu ajouteras les fonctionnalités une fois la base terminée : c'est beaucoup plus gratifiant.
2. Coder sans cahier des charges ni découpage¶
Symptôme : tu sautes d'une fonctionnalité à l'autre, rien n'est fini, tu ne sais plus quoi faire en ouvrant ton éditeur.
Correction : prends 30 minutes pour écrire le cahier des charges et la liste des jalons. À chaque session, choisis un seul jalon et termine-le (tests compris) avant de passer au suivant.
3. Tout mélanger : logique, affichage et saisie¶
Symptôme : impossible de tester une fonction car elle appelle input() ; changer l'affichage casse les calculs ; le fichier principal fait 600 lignes.
Correction : sépare les couches (section 5). Une fonction de calcul reçoit des paramètres et fait return. Seule l'interface fait print et input. Si un fichier dépasse 300 lignes environ, demande-toi s'il n'a pas plusieurs responsabilités.
4. Les tests écrivent dans tes vraies données¶
Symptôme : après avoir lancé pytest, ton budget réel contient des transactions « divers 5 € », ou a été vidé.
Correction : le chemin des données doit être configurable (paramètre, variable d'environnement…) et les tests doivent utiliser tmp_path et monkeypatch. Ne code jamais un chemin réel en dur dans une fonction appelée par les tests.
5. Le perfectionnisme¶
Symptôme : tu réécris pour la cinquième fois la même fonction pour qu'elle soit « parfaite », ou tu n'oses pas publier car « ce n'est pas encore assez bien ».
Correction : « Fait vaut mieux que parfait. » Publie le MVP, puis améliore. Le code n'est jamais parfait, même chez les professionnels. Les tests te permettent de refactoriser plus tard en toute sécurité.
6. Pas de commit pendant des jours¶
Symptôme : un énorme commit « tout le projet », ou pire : une modification malheureuse et plus moyen de revenir à une version qui marchait.
Correction : committe à chaque petite étape qui fonctionne (plusieurs fois par session). Pousse sur GitHub à la fin de chaque session : c'est aussi ta sauvegarde.
7. Publier ses données personnelles ou des secrets¶
Symptôme : ton vrai fichier de budget, ton fichier .env ou une clé d'API se retrouvent dans un dépôt public.
Correction : stocke les données en dehors du dossier du projet (comme ~/.budget/), ou ajoute-les au .gitignore. Vérifie git status avant chaque git add .. Si un secret a été publié, change-le immédiatement (le supprimer dans un nouveau commit ne suffit pas, il reste dans l'historique).
8. Un README qui ne correspond pas à la réalité¶
Symptôme : quelqu'un suit ton README et obtient une erreur : commande renommée, dépendance oubliée, étape manquante.
Correction : à chaque version, fais le test de vérité : clone le projet dans un dossier vide, suis le README à la lettre, dans un venv neuf. Corrige tout ce qui bloque.
Exercices¶
Ces exercices sont les jalons suivants du projet fil rouge. Pour chacun : crée une branche, écris les tests, puis le code, vérifie
ruffetpytest, et fusionne dansmain. Le dernier jalon lance ton propre projet.
Exercice 1 ⭐ — Jalon 1 : Recherche et liste des catégories¶
- Ajoute un paramètre
rechercheàBudget.filtrer: ne garder que les transactions dont la description contient ce texte, sans tenir compte des majuscules. Il doit pouvoir se combiner avec les autres filtres. - Ajoute une méthode
Budget.categories()qui renvoie un dictionnaire{catégorie: nombre de transactions}trié par ordre alphabétique. - Ajoute l'option
--rechercheà la commandelister, et une nouvelle commandebudget categories. - Écris les tests (dont un test paramétré pour la recherche).
Exercice 2 ⭐ — Jalon 2 : Mesurer la couverture des tests¶
- Installe
pytest-covet lancepytest --cov=src --cov-report=term-missing. - Quel est le pourcentage global ? Quel module est le moins couvert, et quelles lignes ne sont jamais exécutées par les tests ?
- Pour chaque ligne non couverte, décide : faut-il écrire un test, ou est-ce acceptable ? Justifie.
- Ajoute
pytest-covaux dépendances de développement danspyproject.toml.
Exercice 3 ⭐⭐ — Jalon 3 : Plafonds de dépenses et alertes¶
Crée un module plafonds.py :
- les plafonds mensuels par catégorie sont stockés dans un fichier
plafonds.json, à côté du fichier de transactions ; - une dataclass
Alerte(categorie, depense, plafond)avec des propriétéstaux(dépense ÷ plafond) etniveau("attention"à partir de 80 %,"dépassé"au-delà de 100 %), et un__str__lisible avec une icône 🟠 ou 🔴 ; - une fonction
verifier_plafonds(budget, plafonds, mois)qui renvoie la liste des alertes du mois.
Ajoute la commande budget plafond : budget plafond courses 250 définit un plafond, budget plafond courses 0 le supprime, budget plafond (sans argument) les affiche. Le bilan --mois affiche les alertes à la fin. Teste le tout.
Exercice 4 ⭐⭐ — Jalon 4 : Un vrai graphique (dépendance facultative)¶
Ajoute une option budget bilan --graphique depenses.png qui enregistre un diagramme en barres horizontales des dépenses par catégorie avec matplotlib (chapitre 14).
Contrainte importante : le cœur du programme doit continuer à fonctionner sans matplotlib. Seule l'option --graphique en a besoin ; si matplotlib est absent, un message clair indique comment l'installer. Déclare la dépendance comme facultative dans pyproject.toml.
Indice : on peut faire un import à l'intérieur d'une fonction : il n'est alors exécuté que si la fonction est appelée.
Exercice 5 ⭐⭐ — Jalon 5 : Intégration continue avec GitHub Actions¶
Crée le fichier .github/workflows/tests.yml pour que GitHub, à chaque push sur main et à chaque pull request :
- installe Python (teste avec deux versions : 3.12 et 3.13) ;
- installe le projet avec ses dépendances de développement ;
- lance
ruff check .,ruff format --check .etpytest.
Pousse, puis observe l'onglet Actions de ton dépôt. Ajoute un badge dans le README.
Exercice 6 ⭐⭐ — Jalon 6 : Publier la version 1.0.0¶
- Ajoute une option
budget --versionqui affichebudget 1.0.0, en lisant la version depuisbudget.__version__(une seule source de vérité… ou presque : où d'autre la version est-elle écrite ?). - Passe la version à
1.0.0, mets à jour le README (feuille de route cochée, nouvelles commandes). - Crée un tag annoté
v1.0.0, pousse-le, et rédige une release GitHub avec la liste des nouveautés depuisv0.1.0. - Vérifie que
pip install "git+https://github.com/TON-PSEUDO/budget-cli.git@v1.0.0"installe bien la bonne version dans un venv neuf.
Exercice 7 ⭐⭐⭐ — Jalon 7 : Une API web sur le même cœur¶
Prouve que la séparation en couches était une bonne idée : ajoute un module api.py qui expose le budget avec FastAPI (chapitre 14), sans modifier modeles.py, budget.py ni stockage.py :
GET /transactions?mois=...&categorie=...: liste ;POST /transactions: ajout (renvoie 201) ; données invalides → 422 ;DELETE /transactions/{id}: suppression (204), identifiant inconnu → 404 ;GET /bilan?mois=...: revenus, dépenses, solde, dépenses par catégorie.
L'API et la ligne de commande doivent partager le même fichier de données. Écris les tests avec TestClient ; ils doivent être ignorés (et non échouer) si FastAPI n'est pas installé.
Exercice 8 ⭐⭐⭐ — Jalon 8 : Le cahier des charges de TON projet¶
Choisis une idée (section 10, ou la tienne) et rédige son dossier de démarrage dans un fichier PROJET.md :
- la description en une phrase de moins de 20 mots ;
- au moins 6 histoires utilisateur classées avec MoSCoW (dont au moins une Won't) ;
- les contraintes techniques (interface, stockage, bibliothèques) ;
- un schéma des modules et de leurs responsabilités ;
- la liste des jalons (5 à 8), le MVP clairement identifié ;
- les critères de réussite.
Puis réalise le premier jalon, la mise en place (dossier, venv, Git, pyproject.toml, structure src/ et tests/) et premier commit.
Mini-projet¶
🚀 Ton projet personnel, de A à Z¶
Le mini-projet de ce chapitre est le plus important de tout le cours : réaliser ton propre projet, en appliquant toute la méthode, jusqu'à la publication.
Cahier des charges¶
Ton projet doit :
- partir du dossier de démarrage de l'exercice 8 (cahier des charges, jalons, MVP) ;
- avoir un MVP fonctionnel de bout en bout, puis au moins une fonctionnalité supplémentaire ;
- être organisé en modules avec une séparation logique / interface, dans une structure
src/+tests/; - utiliser au moins une classe (ou dataclass) et un format de fichier (JSON, CSV…) pour persister des données ;
- avoir au moins 20 tests pytest, dont des cas d'erreur, et utiliser au moins une fixture ;
- passer
ruff checketruff format --check; - avoir un historique Git propre (au moins 10 commits, au moins une branche fusionnée, un tag de version) ;
- avoir un README complet (description, aperçu, installation, utilisation, développement) ;
- être publié sur GitHub et installable en suivant le README ;
- être évalué avec la grille de la section 12 (note ton score dans le README ou dans
PROJET.md).
Étapes suggérées¶
| Semaine | Objectif |
|---|---|
| 1 | Exercice 8 : idée, cahier des charges, découpage, mise en place. Commencer le modèle et ses tests. |
| 2 | Logique et stockage, avec leurs tests. Première version de l'interface. MVP terminé. |
| 3 | README, publication de v0.1.0, test de vérité. Faire essayer le programme à quelqu'un. |
| 4 | Une ou deux fonctionnalités supplémentaires (une branche chacune), CI, version v1.0.0, auto-évaluation. |
Pistes d'amélioration¶
- Ajouter une deuxième interface (web, graphique ou API) sur le même cœur, comme à l'exercice 7.
- Ajouter l'intégration continue (exercice 5) et un badge de couverture.
- Rédiger un court article (blog, LinkedIn, forum) qui raconte ce que tu as construit et appris.
- Proposer à un ami de contribuer : il forke le dépôt, crée une branche et t'envoie une pull request. Tu fais la relecture.
- Contribuer à un projet open source existant : commence par les issues étiquetées « good first issue ».
Corrigés¶
Corrigé de l'exercice 1 (jalon 1)
Dans `budget.py`, on ajoute `Counter` à l'import (`from collections import Counter, defaultdict`), on étend `filtrer` et on ajoute `categories` : def filtrer(
self,
mois: str | None = None,
categorie: str | None = None,
recherche: str | None = None,
) -> "Budget":
"""Renvoie un nouveau Budget filtré par mois, catégorie et/ou texte.
Les filtres sont facultatifs et peuvent être combinés. La recherche
porte sur la description et ignore les majuscules.
"""
resultat = self.transactions
if mois:
resultat = [t for t in resultat if t.jour.strftime("%Y-%m") == mois]
if categorie:
resultat = [t for t in resultat if t.categorie == categorie.lower()]
if recherche:
texte = recherche.lower()
resultat = [t for t in resultat if texte in t.description.lower()]
return Budget(resultat)
def categories(self) -> dict[str, int]:
"""Nombre de transactions par catégorie, par ordre alphabétique."""
compteur = Counter(t.categorie for t in self)
return dict(sorted(compteur.items()))
def cmd_lister(budget: Budget, args: argparse.Namespace) -> bool:
selection = budget.filtrer(
mois=args.mois, categorie=args.categorie, recherche=args.recherche
)
# … (le reste est inchangé)
def cmd_categories(budget: Budget, args: argparse.Namespace) -> bool:
categories = budget.categories()
if not categories:
print("Aucune catégorie pour l'instant.")
for categorie, nombre in categories.items():
print(f" {categorie:<15} {nombre:>3} transaction(s)")
return False
p = sous.add_parser("lister", help="afficher les transactions")
p.add_argument("--mois", help="AAAA-MM")
p.add_argument("--categorie")
p.add_argument("--recherche", help="texte à chercher dans la description")
p.set_defaults(fonction=cmd_lister)
p = sous.add_parser("categories", help="lister les catégories utilisées")
p.set_defaults(fonction=cmd_categories)
import pytest
from budget.budget import Budget
from budget.cli import main
def test_categories(budget_exemple):
assert budget_exemple.categories() == {
"courses": 2,
"loisirs": 1,
"loyer": 2,
"salaire": 2,
}
def test_categories_budget_vide():
assert Budget().categories() == {}
@pytest.mark.parametrize(
"recherche, nombre",
[("salaire", 2), ("SUPER", 1), ("ma", 2), ("introuvable", 0)],
)
def test_recherche(budget_exemple, recherche, nombre):
assert len(budget_exemple.filtrer(recherche=recherche)) == nombre
def test_recherche_combinee(budget_exemple):
assert len(budget_exemple.filtrer(mois="2026-02", recherche="salaire")) == 1
def test_commandes_categories_et_recherche(fichier_donnees, capsys):
main(["ajouter", "depense", "5", "courses", "-d", "Boulangerie"])
main(["ajouter", "depense", "7", "courses", "-d", "Marché bio"])
capsys.readouterr()
main(["categories"])
assert "courses" in capsys.readouterr().out
main(["lister", "--recherche", "bio"])
sortie = capsys.readouterr().out
assert "Marché bio" in sortie and "Boulangerie" not in sortie
Corrigé de l'exercice 2 (jalon 2)
Name Stmts Miss Cover Missing
------------------------------------------------------
src/budget/__init__.py 1 0 100%
src/budget/__main__.py 3 3 0% 3-7
src/budget/budget.py 52 0 100%
src/budget/cli.py 119 10 92% 56-57, 92-94, 107-109, 113, 174
src/budget/modeles.py 32 0 100%
src/budget/stockage.py 37 0 100%
------------------------------------------------------
TOTAL 244 13 95%
def test_lister_vide(fichier_donnees, capsys):
lancer("lister")
assert "Aucune transaction." in capsys.readouterr().out
def test_bilan_global(fichier_donnees, capsys):
lancer("ajouter", "revenu", "10", "x", "--date", "2026-01-05")
capsys.readouterr()
lancer("bilan")
sortie = capsys.readouterr().out
assert "Solde par mois" in sortie and "2026-01" in sortie
def test_import_fichier_absent(fichier_donnees, tmp_path, capsys):
lancer("importer", str(tmp_path / "absent.csv"))
assert "Import impossible" in capsys.readouterr().err
Corrigé de l'exercice 3 (jalon 3)
`src/budget/plafonds.py` :"""Plafonds de dépenses mensuels par catégorie, et alertes de dépassement."""
import json
from dataclasses import dataclass
from pathlib import Path
from budget.budget import Budget
SEUIL_ATTENTION = 0.8 # on prévient à partir de 80 % du plafond
@dataclass
class Alerte:
categorie: str
depense: float
plafond: float
@property
def taux(self) -> float:
return self.depense / self.plafond
@property
def niveau(self) -> str:
return "dépassé" if self.taux > 1 else "attention"
def __str__(self) -> str:
icone = "🔴" if self.niveau == "dépassé" else "🟠"
return (
f"{icone} {self.categorie} : {self.depense:.2f} € dépensés "
f"sur {self.plafond:.2f} € ({self.taux:.0%})"
)
def charger_plafonds(chemin: Path) -> dict[str, float]:
if not chemin.exists():
return {}
return json.loads(chemin.read_text(encoding="utf-8"))
def sauvegarder_plafonds(plafonds: dict[str, float], chemin: Path) -> None:
chemin.parent.mkdir(parents=True, exist_ok=True)
chemin.write_text(json.dumps(plafonds, ensure_ascii=False, indent=2), "utf-8")
def verifier_plafonds(
budget: Budget, plafonds: dict[str, float], mois: str
) -> list[Alerte]:
"""Renvoie les alertes du mois (catégories à 80 % ou plus de leur plafond)."""
depenses = budget.filtrer(mois=mois).depenses_par_categorie()
alertes = []
for categorie, plafond in sorted(plafonds.items()):
depense = depenses.get(categorie, 0.0)
if depense >= SEUIL_ATTENTION * plafond:
alertes.append(Alerte(categorie, depense, plafond))
return alertes
from budget.plafonds import charger_plafonds, sauvegarder_plafonds, verifier_plafonds
def chemin_plafonds() -> Path:
"""Les plafonds sont rangés à côté du fichier de transactions."""
return chemin_donnees().with_name("plafonds.json")
if args.mois:
plafonds = charger_plafonds(chemin_plafonds())
alertes = verifier_plafonds(selection, plafonds, args.mois)
if alertes:
print("\nAlertes :")
for alerte in alertes:
print(f" {alerte}")
else:
print("\nSolde par mois :")
for mois, solde in selection.solde_par_mois().items():
print(f" {mois} {solde:>+10.2f} €")
return False
def cmd_plafond(budget: Budget, args: argparse.Namespace) -> bool:
chemin = chemin_plafonds()
plafonds = charger_plafonds(chemin)
if args.montant is None:
for categorie, montant in sorted(plafonds.items()):
print(f" {categorie:<12} {montant:>9.2f} € / mois")
return False
categorie = args.categorie.strip().lower()
if args.montant <= 0:
plafonds.pop(categorie, None)
print(f"Plafond supprimé pour {categorie}.")
else:
plafonds[categorie] = args.montant
print(f"Plafond de {args.montant:.2f} € par mois pour {categorie}.")
sauvegarder_plafonds(plafonds, chemin)
return False # les transactions, elles, n'ont pas changé
p = sous.add_parser("plafond", help="définir ou afficher les plafonds mensuels")
p.add_argument("categorie", nargs="?", default="")
p.add_argument("montant", type=float, nargs="?", help="0 pour supprimer")
p.set_defaults(fonction=cmd_plafond)
from budget.cli import main
from budget.plafonds import (
Alerte,
charger_plafonds,
sauvegarder_plafonds,
verifier_plafonds,
)
def test_aucune_alerte_sous_80_pourcent(budget_exemple):
# courses de janvier : 117.90 € ; 117.90 / 200 = 59 %
assert verifier_plafonds(budget_exemple, {"courses": 200}, "2026-01") == []
def test_alerte_attention(budget_exemple):
alertes = verifier_plafonds(budget_exemple, {"courses": 140}, "2026-01")
assert alertes == [Alerte("courses", 117.90, 140)]
assert alertes[0].niveau == "attention"
def test_alerte_depassement(budget_exemple):
alertes = verifier_plafonds(budget_exemple, {"loyer": 650}, "2026-02")
assert alertes[0].niveau == "dépassé"
assert str(alertes[0]).startswith("🔴 loyer : 700.00 €")
def test_categorie_sans_depense(budget_exemple):
assert verifier_plafonds(budget_exemple, {"vacances": 500}, "2026-01") == []
def test_aller_retour_fichier(tmp_path):
chemin = tmp_path / "plafonds.json"
assert charger_plafonds(chemin) == {}
sauvegarder_plafonds({"courses": 250.0}, chemin)
assert charger_plafonds(chemin) == {"courses": 250.0}
def test_commande_plafond_et_bilan(fichier_donnees, capsys):
main(["plafond", "Courses", "100"])
main(["ajouter", "depense", "95", "courses", "--date", "2026-06-10"])
capsys.readouterr()
main(["bilan", "--mois", "2026-06"])
assert "🟠 courses : 95.00 € dépensés sur 100.00 € (95%)" in capsys.readouterr().out
main(["plafond", "courses", "0"])
assert charger_plafonds(fichier_donnees.with_name("plafonds.json")) == {}
Corrigé de l'exercice 4 (jalon 4)
`src/budget/graphique.py` :"""Graphique des dépenses par catégorie (nécessite matplotlib)."""
from pathlib import Path
from budget.budget import Budget
def tracer_depenses(budget: Budget, chemin: Path, titre: str) -> None:
"""Enregistre un diagramme en barres des dépenses par catégorie."""
import matplotlib # import ici : le reste du programme marche sans matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
depenses = budget.depenses_par_categorie()
if not depenses:
raise ValueError("Aucune dépense à représenter.")
categories = list(reversed(depenses)) # la plus grosse dépense en haut
montants = [depenses[c] for c in categories]
figure, axe = plt.subplots(figsize=(8, 0.6 * len(categories) + 1.5))
barres = axe.barh(categories, montants, color="#4C72B0")
axe.bar_label(barres, labels=[f"{m:.2f} €" for m in montants], padding=4)
axe.set_title(titre)
axe.set_xlabel("Euros")
axe.set_xlim(0, max(montants) * 1.2)
axe.spines[["top", "right"]].set_visible(False)
figure.tight_layout()
figure.savefig(chemin, dpi=100)
plt.close(figure)
p = sous.add_parser("bilan", help="revenus, dépenses et répartition")
p.add_argument("--mois", help="AAAA-MM")
p.add_argument("--graphique", type=Path, help="enregistre un graphique PNG")
if args.graphique:
try:
tracer_depenses(selection, args.graphique, titre)
print(f"\n📊 Graphique enregistré dans {args.graphique}")
except ImportError:
print("❌ Installe matplotlib : pip install matplotlib", file=sys.stderr)
except ValueError as erreur:
print(f"❌ {erreur}", file=sys.stderr)
[project.optional-dependencies]
graphique = ["matplotlib"]
dev = ["pytest", "pytest-cov", "ruff", "matplotlib"]
import pytest
from budget.budget import Budget
from budget.cli import main
from budget.graphique import tracer_depenses
def test_graphique(budget_exemple, tmp_path):
image = tmp_path / "depenses.png"
tracer_depenses(budget_exemple, image, "Test")
assert image.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n" # signature d'un PNG
def test_graphique_sans_depense(tmp_path):
with pytest.raises(ValueError):
tracer_depenses(Budget(), tmp_path / "vide.png", "Vide")
def test_commande_bilan_graphique(fichier_donnees, tmp_path, capsys):
main(["ajouter", "depense", "12", "courses"])
image = tmp_path / "bilan.png"
main(["bilan", "--graphique", str(image)])
assert image.exists()
assert "Graphique enregistré" in capsys.readouterr().out
Corrigé de l'exercice 5 (jalon 5)
`.github/workflows/tests.yml` :name: Tests
on:
push:
branches: [main]
pull_request:
jobs:
tests:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Installer le projet et les outils
run: pip install -e ".[dev]"
- name: Vérifier le style avec ruff
run: |
ruff check .
ruff format --check .
- name: Lancer les tests
run: pytest
Corrigé de l'exercice 6 (jalon 6)
**1. L'option `--version`** dans `cli.py` : # dans creer_analyseur(), juste après la création de l'analyseur :
analyseur.add_argument(
"--version", action="version", version=f"%(prog)s {__version__}"
)
# après avoir modifié __init__.py, pyproject.toml et le README :
pytest && ruff check .
git commit -am "Version 1.0.0"
git tag -a v1.0.0 -m "Première version stable"
git push
git push origin v1.0.0
git tag -n # liste les tags avec leur message
## Nouveautés depuis v0.1.0
- Recherche dans les descriptions (`lister --recherche`) et commande `categories`
- Plafonds mensuels par catégorie avec alertes dans le bilan
- Graphique des dépenses (`bilan --graphique`, nécessite matplotlib)
- Option `--version`
- Tests automatiques sur Python 3.12 et 3.13
Corrigé de l'exercice 7 (jalon 7)
`src/budget/api.py` :"""Une API web au-dessus du même cœur métier (nécessite fastapi)."""
from datetime import date
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from budget.budget import TransactionIntrouvableError
from budget.cli import chemin_donnees
from budget.modeles import Transaction
from budget.stockage import charger_json, sauvegarder_json
app = FastAPI(title="API Budget")
class NouvelleTransaction(BaseModel):
montant: float = Field(description="négatif pour une dépense")
categorie: str = Field(min_length=1)
description: str = ""
jour: date | None = None
@app.get("/transactions")
def lister(mois: str | None = None, categorie: str | None = None) -> list[dict]:
budget = charger_json(chemin_donnees()).filtrer(mois=mois, categorie=categorie)
return [t.vers_dict() for t in budget.trier_par_date()]
@app.post("/transactions", status_code=201)
def ajouter(nouvelle: NouvelleTransaction) -> dict:
budget = charger_json(chemin_donnees())
try:
transaction = Transaction(
montant=nouvelle.montant,
categorie=nouvelle.categorie,
description=nouvelle.description,
jour=nouvelle.jour or date.today(),
)
except ValueError as erreur:
raise HTTPException(status_code=422, detail=str(erreur)) from erreur
budget.ajouter(transaction)
sauvegarder_json(budget, chemin_donnees())
return transaction.vers_dict()
@app.delete("/transactions/{identifiant}", status_code=204)
def supprimer(identifiant: str) -> None:
budget = charger_json(chemin_donnees())
try:
budget.supprimer(identifiant)
except TransactionIntrouvableError:
raise HTTPException(status_code=404, detail="Transaction introuvable") from None
sauvegarder_json(budget, chemin_donnees())
@app.get("/bilan")
def bilan(mois: str | None = None) -> dict:
selection = charger_json(chemin_donnees()).filtrer(mois=mois)
return {
"revenus": selection.total_revenus,
"depenses": selection.total_depenses,
"solde": selection.solde,
"par_categorie": selection.depenses_par_categorie(),
}
import pytest
pytest.importorskip("fastapi") # ces tests sont ignorés si fastapi est absent
from fastapi.testclient import TestClient # noqa: E402
from budget.api import app # noqa: E402
client = TestClient(app)
def test_scenario_api(fichier_donnees):
reponse = client.post(
"/transactions",
json={"montant": -25.5, "categorie": "Courses", "jour": "2026-05-02"},
)
assert reponse.status_code == 201
identifiant = reponse.json()["id"]
client.post("/transactions", json={"montant": 1000, "categorie": "salaire"})
assert len(client.get("/transactions").json()) == 2
assert client.get("/bilan", params={"mois": "2026-05"}).json() == {
"revenus": 0,
"depenses": 25.5,
"solde": -25.5,
"par_categorie": {"courses": 25.5},
}
assert client.delete(f"/transactions/{identifiant}").status_code == 204
assert client.delete(f"/transactions/{identifiant}").status_code == 404
@pytest.mark.parametrize(
"donnees",
[{"montant": 0, "categorie": "x"}, {"montant": 5, "categorie": ""}, {"montant": 5}],
)
def test_donnees_invalides(fichier_donnees, donnees):
assert client.post("/transactions", json=donnees).status_code == 422
Corrigé de l'exercice 8 (jalon 8)
Il n'y a pas de « bonne réponse » unique, mais voici un exemple complet de `PROJET.md` pour l'idée n° 3 (quiz de vocabulaire) :# Vocabulo — dossier de démarrage
## En une phrase
Un quiz en ligne de commande pour réviser mon vocabulaire d'anglais à partir de mes propres listes de mots.
## Histoires utilisateur
| Priorité | Histoire |
|---|---|
| Must | En tant qu'apprenant, je veux qu'on me pose N questions tirées au hasard d'une liste de mots, afin de réviser. |
| Must | Je veux que les accents et majuscules soient tolérés (« ete » = « été »), afin de ne pas être pénalisé pour une faute de frappe. |
| Must | Je veux voir mon score à la fin, afin de mesurer mes progrès. |
| Should | Je veux ajouter des mots à ma liste depuis le programme, afin de ne pas éditer le JSON à la main. |
| Should | Je veux un historique de mes scores, afin de voir ma progression. |
| Could | Je veux que les mots ratés reviennent plus souvent (révision espacée). |
| Won't | Prononciation audio, application mobile, mode multijoueur. |
## Contraintes techniques
- Python 3.12+, bibliothèque standard uniquement
- Mots en JSON ({"apple": "pomme", ...}), historique en CSV
- Ligne de commande ; logique sans input/print (la fonction de saisie est injectée)
## Modules
- quiz.py : normaliser, est_correcte, choisir_questions, jouer
- stockage.py : charger/sauvegarder les mots, ajouter un score à l'historique
- cli.py : argparse (jouer, ajouter, historique)
## Jalons
0. Mise en place (venv, Git, pyproject, src/ et tests/)
1. normaliser + est_correcte + tests
2. choisir_questions + jouer (avec fonction de saisie injectable) + tests → **MVP**
3. Commande « ajouter »
4. Historique CSV + commande « historique »
5. README, publication v0.1.0
6. Révision espacée (bonus)
## Critères de réussite
MVP + Should fonctionnels et testés (au moins 20 tests), ruff propre,
installable depuis GitHub en suivant le README.
"""Quiz de vocabulaire : MVP d'un projet personnel (exemple du mini-projet)."""
import json
import random
import sys
import unicodedata
from pathlib import Path
FICHIER_MOTS = Path(__file__).with_name("mots.json")
def normaliser(texte: str) -> str:
"""Minuscules, sans accents ni espaces superflus : 'Été ' -> 'ete'."""
decompose = unicodedata.normalize("NFD", texte.strip().lower())
return "".join(c for c in decompose if unicodedata.category(c) != "Mn")
def est_correcte(reponse: str, attendue: str) -> bool:
return normaliser(reponse) == normaliser(attendue)
def charger_mots(chemin: Path) -> dict[str, str]:
return json.loads(chemin.read_text(encoding="utf-8"))
def choisir_questions(mots: dict[str, str], nombre: int, hasard=random) -> list:
"""Tire au sort `nombre` paires (mot, traduction), sans répétition."""
return hasard.sample(list(mots.items()), k=min(nombre, len(mots)))
def jouer(mots: dict[str, str], nombre: int = 5, demander=input) -> int:
"""Pose les questions et renvoie le score. `demander` est remplaçable en test."""
score = 0
for mot, traduction in choisir_questions(mots, nombre):
reponse = demander(f"Traduction de « {mot} » ? ")
if est_correcte(reponse, traduction):
print("✅ Bravo !")
score += 1
else:
print(f"❌ Raté, c'était « {traduction} ».")
return score
def main() -> None:
nombre = int(sys.argv[1]) if len(sys.argv) > 1 else 5
mots = charger_mots(FICHIER_MOTS)
score = jouer(mots, nombre)
print(f"\nScore : {score}/{min(nombre, len(mots))}")
if __name__ == "__main__":
main()
{
"apple": "pomme",
"summer": "été",
"bread": "pain",
"tree": "arbre",
"library": "bibliothèque",
"to learn": "apprendre"
}
import random
import pytest
from quiz import charger_mots, choisir_questions, est_correcte, jouer, normaliser
@pytest.mark.parametrize(
"texte, attendu",
[("Été", "ete"), (" Pomme ", "pomme"), ("BIBLIOTHÈQUE", "bibliotheque")],
)
def test_normaliser(texte, attendu):
assert normaliser(texte) == attendu
def test_reponse_tolerante():
assert est_correcte("ete", "été")
assert not est_correcte("hiver", "été")
@pytest.fixture
def mots():
return {"apple": "pomme", "summer": "été", "bread": "pain"}
def test_choisir_sans_repetition(mots):
questions = choisir_questions(mots, 3, random.Random(1))
assert sorted(questions) == sorted(mots.items())
def test_choisir_pas_plus_que_disponible(mots):
assert len(choisir_questions(mots, 10)) == 3
def test_jouer_tout_juste(mots):
reponses = dict(mots) # question -> bonne réponse
def repondre(question):
mot = question.split("« ")[1].split(" »")[0]
return reponses[mot]
assert jouer(mots, 3, demander=repondre) == 3
def test_jouer_tout_faux(mots):
assert jouer(mots, 3, demander=lambda question: "???") == 0
def test_fichier_de_mots_valide():
from quiz import FICHIER_MOTS
mots = charger_mots(FICHIER_MOTS)
assert len(mots) >= 5
assert all(isinstance(v, str) and v for v in mots.values())
Corrigé du mini-projet
Le mini-projet est **ton** projet : il n'a pas de corrigé unique. Voici une **liste de vérification finale**, inspirée des erreurs les plus courantes, à passer avant de considérer ton projet comme terminé. **Fonctionnement** - [ ] Je peux décrire mon projet en une phrase, et le README commence par cette phrase. - [ ] Toutes les fonctionnalités *Must* fonctionnent dans un venv neuf, installé en suivant **uniquement** le README. - [ ] Une saisie invalide (texte au lieu d'un nombre, fichier absent, identifiant inconnu…) produit un message clair, jamais un traceback. - [ ] Mes données ne sont jamais corrompues ni perdues si le programme s'arrête en cours de route (on écrit le fichier à la fin d'une opération réussie). **Code** - [ ] La logique est séparée de l'interface : aucune fonction de calcul ne fait `print` ni `input`. - [ ] Aucun fichier ne dépasse ~300 lignes ; aucune fonction ne dépasse ~40 lignes. - [ ] Les noms sont explicites, les fonctions publiques ont une docstring. - [ ] `ruff check .` et `ruff format --check .` ne signalent rien. **Tests** - [ ] Au moins 20 tests, qui passent tous. - [ ] Les cas d'erreur et les cas limites (vide, zéro, seuil) sont testés. - [ ] Les tests n'utilisent que des fichiers temporaires (`tmp_path`) et ne dépendent pas de l'ordre d'exécution. **Git et publication** - [ ] Au moins 10 commits aux messages clairs, au moins une branche fusionnée. - [ ] `.venv`, `__pycache__`, mes données personnelles et mes secrets ne sont **pas** dans le dépôt. - [ ] Le projet est sur GitHub, avec un tag de version et une *release*. - [ ] (Bonus) La CI GitHub Actions est verte. **Auto-évaluation** - [ ] J'ai rempli la grille de la section 12 et noté mon score. - [ ] J'ai identifié mes deux critères les plus faibles et une action concrète pour chacun. **Exemple de bilan rédigé** (à mettre à la fin de `PROJET.md`) :## Bilan v1.0.0
Score : 25/32 (Fonctionnalités 4, Organisation 3, Lisibilité 3, Erreurs 3,
Tests 4, Git 3, Documentation 3, Publication 2)
Points forts : 38 tests, logique entièrement couverte, plafonds bien pratiques.
À améliorer :
- Publication : ajouter la CI GitHub Actions (exercice 5).
- Documentation : ajouter une capture du bilan dans le README.
Ce que j'ai appris : les sous-commandes argparse, monkeypatch, l'importance
du test d'aller-retour JSON (il a trouvé un bug de date !).
Prochaine étape : une interface web avec Flask.
Récapitulatif¶
Points clés à retenir :
- Un projet personnel est la meilleure façon de consolider tes connaissances : un petit projet terminé vaut mieux qu'un grand projet abandonné.
- Choisis une idée utile ou amusante pour toi, et dix fois plus petite que ce que tu imagines.
- Cahier des charges : une phrase de description, des histoires utilisateur « En tant que… je veux… afin de… », des priorités MoSCoW (dont une liste Won't), des contraintes et des critères de réussite.
- MVP : la plus petite version utilisable de bout en bout (la trottinette avant la voiture). Puis des jalons de quelques heures, chacun testé et commité.
- Architecture en couches : modèle, logique, stockage, interface. La logique ne fait ni
printniinput: elle se teste facilement et peut changer d'interface (CLI → API) sans être modifiée. - Git dès la première minute, une branche par fonctionnalité, des commits fréquents, des tags de version.
- Tests au fil de l'eau : logique, aller-retour de stockage, cas d'erreur, interface (
capsys,monkeypatch,tmp_path). - README : description, aperçu, installation, utilisation, développement. Fais le test de vérité dans un venv neuf.
- Publication : GitHub, release taguée, installation par
pip install git+…, intégration continue avec GitHub Actions. - Auto-évaluation régulière avec la grille, pour savoir quoi améliorer ensuite.
Checklist :
- J'ai réalisé le projet fil rouge Budget CLI jusqu'au MVP (46 tests verts).
- J'ai réalisé au moins trois jalons supplémentaires du projet fil rouge.
- J'ai choisi l'idée de mon projet personnel et rédigé son cahier des charges.
- J'ai découpé mon projet en jalons et identifié le MVP.
- Mon projet est organisé en couches, avec
src/ettests/. - Mon projet a au moins 20 tests et passe
ruff. - Mon projet a un README complet et a passé le test de vérité.
- Mon projet est publié sur GitHub avec un tag de version.
- Je me suis évalué avec la grille et j'ai noté mes prochains objectifs.
Et maintenant ? Garde l'aide-mémoire sous la main, pioche dans les ressources pour approfondir ta spécialisation, et surtout : continue à construire des choses. Chaque projet te rendra plus à l'aise que le précédent. Bon code ! 🐍