2620 lines
109 KiB
Markdown
2620 lines
109 KiB
Markdown
# 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](14-specialisations.md), et en particulier maîtriser les outils du [Chapitre 12](12-outils-venv-pip-git.md) (venv, Git, GitHub) et du [Chapitre 13](13-tests-pytest-bonnes-pratiques.md) (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](00-installation-environnement.md) : 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 :
|
||
|
||
1. **la méthode** : les huit étapes d'un projet, de l'idée à la publication (sections 1 à 9) ;
|
||
2. **un projet fil rouge entièrement guidé** : un gestionnaire de budget personnel en ligne de commande, construit pas à pas (section 11) ;
|
||
3. **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) |
|
||
|---|---|---|
|
||
| **M**ust (indispensable) | sans elle, le programme n'a pas de sens | ajouter, lister, bilan, sauvegarde |
|
||
| **S**hould (important) | très utile, mais on peut s'en passer au début | supprimer, filtrer par mois, export CSV |
|
||
| **C**ould (bonus) | agréable si on a le temps | graphique, plafonds d'alerte |
|
||
| **W**on'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.
|
||
|
||
```text
|
||
❌ 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.md` ou 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** :
|
||
|
||
```text
|
||
┌─────────────────────────────────────────────┐
|
||
│ 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](13-tests-pytest-bonnes-pratiques.md) :
|
||
|
||
```text
|
||
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](12-outils-venv-pip-git.md)) :
|
||
|
||
- **`git init` dè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 dans `main` quand c'est terminé et testé. `main` doit **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é :
|
||
|
||
```bash
|
||
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](13-tests-pytest-bonnes-pratiques.md)).
|
||
|
||
Que tester en priorité ?
|
||
|
||
1. **La logique** (calculs, règles) : c'est là que se cachent les bugs les plus sournois, et c'est le plus facile à tester.
|
||
2. **Le stockage** : un aller-retour « sauvegarder puis recharger » doit redonner exactement les mêmes données. Utilise `tmp_path`.
|
||
3. **Les cas d'erreur** : montant nul, fichier corrompu, identifiant inconnu…
|
||
4. **L'interface** : quelques tests « de bout en bout » qui lancent une commande et vérifient l'affichage (fixture `capsys` de 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 :
|
||
|
||
1. **Titre et description** en une ou deux phrases ;
|
||
2. **Fonctionnalités** (liste à puces) ;
|
||
3. **Aperçu** : un exemple de sortie du terminal ou une capture d'écran ;
|
||
4. **Installation** : les commandes exactes, copiables ;
|
||
5. **Utilisation** : les commandes principales avec un exemple ;
|
||
6. **Développement** : comment lancer les tests ;
|
||
7. **Feuille de route** (idées futures), **licence**, **auteur**.
|
||
|
||
Les docstrings ([chapitre 13](13-tests-pytest-bonnes-pratiques.md)) 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 :
|
||
|
||
1. **Pousser sur GitHub** (dépôt public) avec un README soigné. C'est le minimum.
|
||
2. **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.
|
||
3. **Rendre le projet installable** : grâce au `pyproject.toml`, n'importe qui peut l'installer directement depuis GitHub :
|
||
|
||
```bash
|
||
pip install "git+https://github.com/TON-PSEUDO/budget-cli.git@v1.0.0"
|
||
```
|
||
|
||
4. **Automatiser les tests** avec GitHub Actions (intégration continue) : à chaque `push`, GitHub lance ruff et pytest et affiche ✅ ou ❌.
|
||
5. **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 :
|
||
|
||
1. **Lis le message d'erreur** en entier, en partant du bas du traceback ([chapitre 08](08-erreurs-et-debogage.md)).
|
||
2. **Reproduis le problème** dans le plus petit cas possible (idéalement, un test qui échoue).
|
||
3. **Affiche ou débogue** : `print` des valeurs, ou point d'arrêt dans VS Code.
|
||
4. **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.
|
||
5. **Cherche** : documentation officielle, recherche web avec le message d'erreur exact, forums.
|
||
6. **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.
|
||
7. **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 :
|
||
|
||
```text
|
||
┌───────────────────────┐
|
||
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 `montant` signé** : 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é.
|
||
- **`Transaction` est une `dataclass`** ([chapitre 11](11-poo-avancee-heritage-typage.md)) : on obtient `__init__`, `__repr__` et `__eq__` gratuitement (le `__eq__` sera très pratique dans les tests).
|
||
- **`Budget.filtrer` renvoie un nouveau `Budget`** : 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
|
||
|
||
```bash
|
||
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` :
|
||
|
||
```gitignore
|
||
.venv/
|
||
__pycache__/
|
||
*.pyc
|
||
.pytest_cache/
|
||
.ruff_cache/
|
||
*.egg-info/
|
||
build/
|
||
dist/
|
||
.env
|
||
.coverage
|
||
```
|
||
|
||
Puis le `pyproject.toml` :
|
||
|
||
```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` :
|
||
|
||
```python
|
||
"""Gestionnaire de budget personnel en ligne de commande."""
|
||
|
||
__version__ = "0.1.0"
|
||
```
|
||
|
||
Un `README.md` provisoire (`# Budget CLI`), puis :
|
||
|
||
```bash
|
||
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` :
|
||
|
||
```python
|
||
"""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). Une `Transaction` invalide 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_dict` est une **méthode de classe** (`@classmethod`) : elle fabrique une nouvelle instance.
|
||
- `donnees.get("description") or ""` : fonctionne si la clé est absente **ou** vide (`None` dans un CSV incomplet).
|
||
|
||
Les fixtures partagées, dans `tests/conftest.py` :
|
||
|
||
```python
|
||
"""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` :
|
||
|
||
```python
|
||
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"
|
||
```
|
||
|
||
```bash
|
||
pytest tests/test_modeles.py
|
||
git add . && git commit -m "Ajoute le modèle Transaction"
|
||
```
|
||
|
||
#### 11.5 Étape 2 : la logique `Budget`
|
||
|
||
`src/budget/budget.py` :
|
||
|
||
```python
|
||
"""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 écrire `len(budget)` et `for t in budget:` comme avec une liste.
|
||
- `list(transactions or [])` : on fait une **copie** de la liste reçue. Sinon, `filtrer` partagerait la même liste que l'original et un `ajouter` sur le résultat filtré modifierait aussi l'original ! (Un test vérifie justement ce point.)
|
||
- `TransactionIntrouvableError` hérite de `KeyError` : c'est une erreur de « clé introuvable », avec un nom plus parlant ([chapitre 08](08-erreurs-et-debogage.md)).
|
||
- `round(..., 2)` partout : les `float` produisent parfois des résultats comme `117.89999999999999`. On arrondit au centime les montants affichés et comparés.
|
||
- `defaultdict(float)` ([chapitre 09](09-modules-bibliotheque-standard.md)) : une clé absente vaut automatiquement `0.0`, pratique pour accumuler.
|
||
- `min(..., default=None)` : renvoie `None` au lieu de planter si le budget est vide.
|
||
|
||
`tests/test_budget.py` :
|
||
|
||
```python
|
||
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)
|
||
```
|
||
|
||
```bash
|
||
pytest
|
||
git add . && git commit -m "Ajoute la classe Budget et ses calculs"
|
||
```
|
||
|
||
#### 11.6 Étape 3 : le stockage JSON et CSV
|
||
|
||
`src/budget/stockage.py` :
|
||
|
||
```python
|
||
"""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=False` garde les accents lisibles dans le fichier JSON (« Supermarché » au lieu de `Supermarché`) ; `indent=2` le 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 `ValueError` **avec un message clair**, transformée plus haut en message pour l'utilisateur. `from erreur` garde la cause d'origine pour le débogage.
|
||
- `csv.DictWriter` / `csv.DictReader` ([chapitre 07](07-fichiers-csv-json.md)) travaillent avec des dictionnaires : ils se combinent parfaitement avec `vers_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` :
|
||
|
||
```python
|
||
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.
|
||
|
||
```bash
|
||
pytest
|
||
git add . && git commit -m "Ajoute la sauvegarde JSON et l'export/import CSV"
|
||
```
|
||
|
||
#### 11.7 Étape 4 : l'interface en ligne de commande (le MVP !)
|
||
|
||
`src/budget/cli.py` :
|
||
|
||
```python
|
||
"""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` :
|
||
|
||
```python
|
||
"""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, comme `git add` / `git commit`. `set_defaults(fonction=cmd_ajouter)` associe chaque sous-commande à la fonction qui la traite : `main` n'a plus qu'à appeler `args.fonction(...)`, sans grande cascade de `if/elif`.
|
||
- **Toutes les commandes ont la même signature** `(budget, args) -> bool` et renvoient `True` si le budget a été modifié. `main` ne sauvegarde que dans ce cas : un simple `lister` ne 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 `-30` par erreur (il faut alors écrire `budget ajouter depense -- -30 loisirs`, car argparse prendrait `-30` pour une option ; `--` signifie « fin des options »).
|
||
- **Les erreurs** vont sur `sys.stderr` (la sortie d'erreur) et `main` renvoie 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` :
|
||
|
||
```python
|
||
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 :**
|
||
|
||
- `capsys` capture ce que le programme affiche : `capsys.readouterr().out` (sortie normale) et `.err` (erreurs).
|
||
- `monkeypatch` (utilisée dans notre fixture `fichier_donnees`) modifie temporairement l'environnement ; ici, elle fait pointer `BUDGET_FICHIER` vers un dossier temporaire. **Tes vraies données ne sont jamais touchées par les tests.**
|
||
|
||
```bash
|
||
pytest
|
||
```
|
||
|
||
```text
|
||
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 ==============================
|
||
```
|
||
|
||
```bash
|
||
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 :
|
||
|
||
```bash
|
||
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
|
||
```
|
||
|
||
```text
|
||
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 :
|
||
|
||
```bash
|
||
export BUDGET_FICHIER=essai.json # macOS / Linux
|
||
```
|
||
|
||
```powershell
|
||
$env:BUDGET_FICHIER = "essai.json" # Windows PowerShell
|
||
```
|
||
|
||
Et pour exporter vers un tableur : `budget exporter mars.csv`.
|
||
|
||
#### 11.9 Le README
|
||
|
||
`README.md` :
|
||
|
||
````markdown
|
||
# 💶 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
|
||
````
|
||
|
||
```bash
|
||
git add README.md && git commit -m "Rédige le README"
|
||
```
|
||
|
||
#### 11.10 Publication
|
||
|
||
```bash
|
||
# 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 :
|
||
|
||
```bash
|
||
pip install "git+https://github.com/TON-PSEUDO/budget-cli.git@v0.1.0"
|
||
budget --help
|
||
```
|
||
|
||
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 `ruff` et `pytest`, et fusionne dans `main`. Le dernier jalon lance ton propre projet.
|
||
|
||
### Exercice 1 ⭐ — Jalon 1 : Recherche et liste des catégories
|
||
|
||
1. 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.
|
||
2. Ajoute une méthode `Budget.categories()` qui renvoie un dictionnaire `{catégorie: nombre de transactions}` trié par ordre alphabétique.
|
||
3. Ajoute l'option `--recherche` à la commande `lister`, et une nouvelle commande `budget categories`.
|
||
4. Écris les tests (dont un test paramétré pour la recherche).
|
||
|
||
### Exercice 2 ⭐ — Jalon 2 : Mesurer la couverture des tests
|
||
|
||
1. Installe `pytest-cov` et lance `pytest --cov=src --cov-report=term-missing`.
|
||
2. Quel est le pourcentage global ? Quel module est le moins couvert, et quelles lignes ne sont jamais exécutées par les tests ?
|
||
3. Pour **chaque** ligne non couverte, décide : faut-il écrire un test, ou est-ce acceptable ? Justifie.
|
||
4. Ajoute `pytest-cov` aux dépendances de développement dans `pyproject.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és `taux` (dépense ÷ plafond) et `niveau` (`"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](14-specialisations.md)).
|
||
|
||
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* :
|
||
|
||
1. installe Python (teste avec **deux** versions : 3.12 et 3.13) ;
|
||
2. installe le projet avec ses dépendances de développement ;
|
||
3. lance `ruff check .`, `ruff format --check .` et `pytest`.
|
||
|
||
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
|
||
|
||
1. Ajoute une option `budget --version` qui affiche `budget 1.0.0`, en lisant la version depuis `budget.__version__` (une seule source de vérité… ou presque : où d'autre la version est-elle écrite ?).
|
||
2. Passe la version à `1.0.0`, mets à jour le README (feuille de route cochée, nouvelles commandes).
|
||
3. Crée un tag **annoté** `v1.0.0`, pousse-le, et rédige une *release* GitHub avec la liste des nouveautés depuis `v0.1.0`.
|
||
4. 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](14-specialisations.md)), **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` :
|
||
|
||
1. la description en une phrase de moins de 20 mots ;
|
||
2. au moins 6 histoires utilisateur classées avec MoSCoW (dont au moins une *Won't*) ;
|
||
3. les contraintes techniques (interface, stockage, bibliothèques) ;
|
||
4. un schéma des modules et de leurs responsabilités ;
|
||
5. la liste des jalons (5 à 8), le MVP clairement identifié ;
|
||
6. 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 :
|
||
|
||
1. partir du dossier de démarrage de l'**exercice 8** (cahier des charges, jalons, MVP) ;
|
||
2. avoir un **MVP fonctionnel** de bout en bout, puis au moins **une fonctionnalité** supplémentaire ;
|
||
3. être organisé en **modules** avec une séparation logique / interface, dans une structure `src/` + `tests/` ;
|
||
4. utiliser au moins **une classe** (ou dataclass) et **un format de fichier** (JSON, CSV…) pour persister des données ;
|
||
5. avoir **au moins 20 tests** pytest, dont des cas d'erreur, et utiliser au moins une fixture ;
|
||
6. passer `ruff check` et `ruff format --check` ;
|
||
7. avoir un historique Git propre (au moins 10 commits, au moins une branche fusionnée, un tag de version) ;
|
||
8. avoir un **README** complet (description, aperçu, installation, utilisation, développement) ;
|
||
9. être **publié** sur GitHub et installable en suivant le README ;
|
||
10. ê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
|
||
|
||
<details>
|
||
<summary>Corrigé de l'exercice 1 (jalon 1)</summary>
|
||
|
||
Dans `budget.py`, on ajoute `Counter` à l'import (`from collections import Counter, defaultdict`), on étend `filtrer` et on ajoute `categories` :
|
||
|
||
```python
|
||
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()))
|
||
```
|
||
|
||
Dans `cli.py`, la commande `lister` transmet la recherche, et une nouvelle commande apparaît :
|
||
|
||
```python
|
||
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
|
||
```
|
||
|
||
Et dans `creer_analyseur` :
|
||
|
||
```python
|
||
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)
|
||
```
|
||
|
||
Tests (dans un nouveau fichier `tests/test_recherche.py`) :
|
||
|
||
```python
|
||
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
|
||
```
|
||
|
||
**Explications** :
|
||
|
||
- Le cas `("ma", 2)` est intéressant : « ma » se trouve dans « **Ma**rché » et dans « Supermarché » (« super**ma**rché »). Les tests révèlent souvent des comportements auxquels on n'avait pas pensé ; ici, c'est bien le comportement voulu (recherche de sous-chaîne).
|
||
- `Counter` compte les catégories en une ligne ; `dict(sorted(...))` produit un dictionnaire trié par clé.
|
||
- Les **anciens tests passent toujours** : le nouveau paramètre est facultatif, rien n'est cassé. C'est l'intérêt d'une suite de tests : tu le sais en une seconde.
|
||
|
||
</details>
|
||
|
||
<details>
|
||
<summary>Corrigé de l'exercice 2 (jalon 2)</summary>
|
||
|
||
```bash
|
||
pip install pytest-cov
|
||
pytest --cov=src --cov-report=term-missing
|
||
```
|
||
|
||
```text
|
||
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%
|
||
```
|
||
|
||
*(Les numéros de lignes exacts dépendent de ta version du code.)*
|
||
|
||
**Analyse** :
|
||
|
||
- **95 % au total**, et 100 % sur le modèle, la logique et le stockage : le cœur est entièrement testé. 🎉
|
||
- `__main__.py` (0 %) : il ne contient que l'appel à `main()`. Acceptable : on pourrait le tester en lançant un sous-processus (`subprocess.run([sys.executable, "-m", "budget", "--help"])`), mais le gain est faible.
|
||
- Dans `cli.py`, les lignes non couvertes correspondent à :
|
||
- `lister` sur un budget vide (« Aucune transaction. ») → **mérite un test**, c'est un cas que tout nouvel utilisateur rencontre ;
|
||
- le bloc « Solde par mois » du bilan global → **mérite un test** (`budget bilan` sans `--mois`) ;
|
||
- l'import d'un fichier CSV invalide ou absent → **mérite un test** (cas d'erreur) ;
|
||
- `if __name__ == "__main__":` → acceptable, jamais exécuté par les tests par nature.
|
||
|
||
Exemple de tests ajoutés :
|
||
|
||
```python
|
||
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
|
||
```
|
||
|
||
Dans `pyproject.toml` :
|
||
|
||
```toml
|
||
[project.optional-dependencies]
|
||
dev = ["pytest", "pytest-cov", "ruff"]
|
||
```
|
||
|
||
**À retenir** : la couverture indique ce qui n'est **pas** testé ; elle ne prouve pas que ce qui est couvert est **bien** testé (une ligne exécutée sans `assert` pertinent compte quand même). Vise une couverture élevée sur la logique, et sers-toi du rapport pour repérer les oublis.
|
||
|
||
</details>
|
||
|
||
<details>
|
||
<summary>Corrigé de l'exercice 3 (jalon 3)</summary>
|
||
|
||
`src/budget/plafonds.py` :
|
||
|
||
```python
|
||
"""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
|
||
```
|
||
|
||
Modifications de `cli.py` :
|
||
|
||
```python
|
||
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")
|
||
```
|
||
|
||
À la fin de `cmd_bilan`, le `if not args.mois:` devient :
|
||
|
||
```python
|
||
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
|
||
```
|
||
|
||
Nouvelle commande :
|
||
|
||
```python
|
||
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é
|
||
```
|
||
|
||
Dans `creer_analyseur` :
|
||
|
||
```python
|
||
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)
|
||
```
|
||
|
||
`tests/test_plafonds.py` :
|
||
|
||
```python
|
||
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")) == {}
|
||
```
|
||
|
||
Utilisation :
|
||
|
||
```text
|
||
$ budget plafond courses 80
|
||
Plafond de 80.00 € par mois pour courses.
|
||
$ budget plafond loisirs 100
|
||
$ budget bilan --mois 2026-03
|
||
…
|
||
Alertes :
|
||
🔴 courses : 88.10 € dépensés sur 80.00 € (110%)
|
||
🟠 loisirs : 85.00 € dépensés sur 100.00 € (85%)
|
||
```
|
||
|
||
**Explications** :
|
||
|
||
- Le module `plafonds.py` ne fait **aucun affichage** : il calcule des `Alerte`, et c'est la CLI qui les imprime. On respecte la séparation en couches.
|
||
- `nargs="?"` rend un argument positionnel **facultatif** : `budget plafond` (affichage), `budget plafond courses 250` (définition).
|
||
- `chemin_donnees().with_name("plafonds.json")` : même dossier que les transactions, autre nom. Grâce à cela, la fixture `fichier_donnees` redirige **aussi** les plafonds vers le dossier temporaire, sans rien changer aux tests existants.
|
||
- `{self.taux:.0%}` : le format `%` multiplie par 100 et ajoute le symbole (0.95 → `95%`).
|
||
- Le seuil est une **constante nommée** (`SEUIL_ATTENTION`) plutôt qu'un `0.8` perdu dans le code : plus lisible et facile à changer.
|
||
|
||
</details>
|
||
|
||
<details>
|
||
<summary>Corrigé de l'exercice 4 (jalon 4)</summary>
|
||
|
||
`src/budget/graphique.py` :
|
||
|
||
```python
|
||
"""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)
|
||
```
|
||
|
||
Dans `cli.py` : `from budget.graphique import tracer_depenses`, l'option dans l'analyseur…
|
||
|
||
```python
|
||
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")
|
||
```
|
||
|
||
… et dans `cmd_bilan`, juste avant le bloc des alertes / du solde par mois :
|
||
|
||
```python
|
||
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)
|
||
```
|
||
|
||
Dans `pyproject.toml`, une dépendance **facultative** :
|
||
|
||
```toml
|
||
[project.optional-dependencies]
|
||
graphique = ["matplotlib"]
|
||
dev = ["pytest", "pytest-cov", "ruff", "matplotlib"]
|
||
```
|
||
|
||
L'utilisateur qui veut les graphiques installe `pip install -e ".[graphique]"`.
|
||
|
||
Tests (`tests/test_graphique.py`) :
|
||
|
||
```python
|
||
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
|
||
```
|
||
|
||
**Explications** :
|
||
|
||
- **L'import à l'intérieur de la fonction** est la clé : `import budget.graphique` réussit même sans matplotlib, et l'`ImportError` ne survient qu'à l'appel de `tracer_depenses`, où la CLI l'attrape pour afficher un conseil. Le cœur du programme reste sans dépendance.
|
||
- `matplotlib.use("Agg")` : on enregistre un fichier, on n'ouvre pas de fenêtre (fonctionne aussi sur un serveur ou dans la CI).
|
||
- `reversed(depenses)` : `depenses_par_categorie` est trié du plus gros au plus petit, et `barh` dessine le premier élément en bas ; on inverse pour avoir la plus grosse dépense en haut.
|
||
- Tester une image pixel par pixel serait fragile. On vérifie simplement que le fichier existe et commence par la **signature** des fichiers PNG : suffisant pour attraper les vraies régressions.
|
||
- La hauteur de la figure s'adapte au nombre de catégories (`0.6 * len(categories) + 1.5`).
|
||
|
||
</details>
|
||
|
||
<details>
|
||
<summary>Corrigé de l'exercice 5 (jalon 5)</summary>
|
||
|
||
`.github/workflows/tests.yml` :
|
||
|
||
```yaml
|
||
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
|
||
```
|
||
|
||
```bash
|
||
git add .github/workflows/tests.yml
|
||
git commit -m "Ajoute l'intégration continue"
|
||
git push
|
||
```
|
||
|
||
Dans l'onglet **Actions** du dépôt, deux tâches apparaissent (une par version de Python), avec une coche verte ✅ si tout passe. En cas d'échec ❌, clique dessus pour voir exactement quelle étape a échoué et pourquoi.
|
||
|
||
Badge pour le README (remplace `TON-PSEUDO`) :
|
||
|
||
```markdown
|
||

|
||
```
|
||
|
||
**Explications** :
|
||
|
||
- Le fichier est en **YAML** : l'indentation (avec des espaces) y est significative, comme en Python.
|
||
- `on:` définit **quand** le workflow se lance ; `jobs:` **quoi** faire ; `runs-on: ubuntu-latest` sur une machine Linux fournie gratuitement par GitHub (pour les dépôts publics).
|
||
- `strategy.matrix` lance le même travail pour chaque version de Python : tu détectes ainsi une incompatibilité avant tes utilisateurs.
|
||
- `actions/checkout` récupère ton code, `actions/setup-python` installe Python : ce sont des « actions » réutilisables publiées par GitHub (les numéros de version `@v4`, `@v5` évoluent : vérifie les dernières dans leur documentation).
|
||
- Pourquoi c'est précieux : même si tu oublies de lancer les tests avant un `push`, GitHub, lui, n'oublie jamais. Et sur une *pull request*, tu vois immédiatement si la contribution casse quelque chose.
|
||
- Pour que les tests passent sur la CI, ils ne doivent dépendre **de rien de local** (ni de tes fichiers, ni de ton dossier personnel) : c'est pour ça qu'on a utilisé `tmp_path` et `monkeypatch` partout.
|
||
|
||
</details>
|
||
|
||
<details>
|
||
<summary>Corrigé de l'exercice 6 (jalon 6)</summary>
|
||
|
||
**1. L'option `--version`** dans `cli.py` :
|
||
|
||
```python
|
||
# en haut de cli.py, avec les autres imports :
|
||
from budget import __version__
|
||
```
|
||
|
||
```python
|
||
# dans creer_analyseur(), juste après la création de l'analyseur :
|
||
analyseur.add_argument(
|
||
"--version", action="version", version=f"%(prog)s {__version__}"
|
||
)
|
||
```
|
||
|
||
```text
|
||
$ budget --version
|
||
budget 1.0.0
|
||
```
|
||
|
||
`action="version"` est prévu par argparse : il affiche le texte et quitte le programme. `%(prog)s` est remplacé par le nom du programme.
|
||
|
||
La version est écrite à **deux** endroits : `src/budget/__init__.py` (`__version__ = "1.0.0"`) et `pyproject.toml` (`version = "1.0.0"`). Il faut penser à modifier les deux (une solution avancée consiste à déclarer `dynamic = ["version"]` dans `pyproject.toml` pour que l'outil de construction lise `__version__` ; ou, à l'inverse, à lire la version installée avec `importlib.metadata.version("budget-cli")`).
|
||
|
||
**2–3. Version, tag, release** :
|
||
|
||
```bash
|
||
# 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
|
||
```
|
||
|
||
Sur GitHub : *Releases → Draft a new release → tag v1.0.0*, avec par exemple :
|
||
|
||
```markdown
|
||
## 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
|
||
```
|
||
|
||
**4. Vérification** dans un venv neuf :
|
||
|
||
```bash
|
||
python3 -m venv /tmp/essai && source /tmp/essai/bin/activate
|
||
pip install "git+https://github.com/TON-PSEUDO/budget-cli.git@v1.0.0"
|
||
budget --version # budget 1.0.0
|
||
deactivate
|
||
```
|
||
|
||
**Explications** :
|
||
|
||
- Un **tag annoté** (`-a`) stocke un message, un auteur et une date : c'est le type recommandé pour les versions. Les tags ne sont **pas** poussés par un simple `git push` : il faut les pousser explicitement.
|
||
- `@v1.0.0` à la fin de l'URL installe exactement cette version, même si `main` a évolué depuis.
|
||
- Passer de `0.x` à `1.0.0` est un signal : « le programme est stable, son utilisation ne changera pas brutalement ».
|
||
|
||
</details>
|
||
|
||
<details>
|
||
<summary>Corrigé de l'exercice 7 (jalon 7)</summary>
|
||
|
||
`src/budget/api.py` :
|
||
|
||
```python
|
||
"""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(),
|
||
}
|
||
```
|
||
|
||
`tests/test_api.py` :
|
||
|
||
```python
|
||
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
|
||
```
|
||
|
||
Dans `pyproject.toml` : `api = ["fastapi[standard]"]` dans `[project.optional-dependencies]`. Lancement :
|
||
|
||
```bash
|
||
pip install -e ".[api]"
|
||
fastapi dev src/budget/api.py # puis http://127.0.0.1:8000/docs
|
||
```
|
||
|
||
**Explications** :
|
||
|
||
- **Aucune ligne** de `modeles.py`, `budget.py` ou `stockage.py` n'a été modifiée : l'API réutilise exactement la même logique et le même fichier (`chemin_donnees()`) que la CLI. Une transaction ajoutée par `budget ajouter` apparaît dans `GET /transactions`, et inversement. C'est la récompense d'une bonne séparation en couches.
|
||
- Deux niveaux de validation : pydantic vérifie la **forme** (champs présents, types, catégorie non vide) ; la dataclass `Transaction` vérifie les **règles métier** (montant non nul). On transforme sa `ValueError` en réponse HTTP 422.
|
||
- `pytest.importorskip("fastapi")` **ignore** le fichier de tests (`s` dans le rapport) si FastAPI n'est pas installé, au lieu de faire échouer toute la suite. Les `# noqa: E402` indiquent à ruff que les imports placés après sont volontaires.
|
||
- Les tests réutilisent la fixture `fichier_donnees` de `conftest.py` : l'API écrit, elle aussi, dans un dossier temporaire.
|
||
- Limite assumée : chaque requête relit et réécrit tout le fichier JSON. C'est parfait pour un usage personnel, mais pour de nombreux utilisateurs simultanés, il faudrait une base de données (SQLite, puis PostgreSQL). C'est l'étape suivante naturelle.
|
||
|
||
</details>
|
||
|
||
<details>
|
||
<summary>Corrigé de l'exercice 8 (jalon 8)</summary>
|
||
|
||
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) :
|
||
|
||
```markdown
|
||
# 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.
|
||
```
|
||
|
||
Et le cœur du MVP (jalons 1 et 2), dans une version simplifiée en un seul fichier pour démarrer (`quiz.py`) :
|
||
|
||
```python
|
||
"""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()
|
||
```
|
||
|
||
`mots.json` :
|
||
|
||
```json
|
||
{
|
||
"apple": "pomme",
|
||
"summer": "été",
|
||
"bread": "pain",
|
||
"tree": "arbre",
|
||
"library": "bibliothèque",
|
||
"to learn": "apprendre"
|
||
}
|
||
```
|
||
|
||
`test_quiz.py` :
|
||
|
||
```python
|
||
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())
|
||
```
|
||
|
||
Résultat : `9 passed`.
|
||
|
||
**Points clés** :
|
||
|
||
- **Le paramètre `demander=input`** est l'astuce qui rend testable une fonction interactive : en usage normal, c'est `input` ; dans les tests, on passe une fonction qui répond automatiquement. On appelle ça l'**injection de dépendance**.
|
||
- `hasard=random` permet de même de passer un générateur à graine fixe (`random.Random(1)`) dans les tests.
|
||
- `random.sample` tire **sans remise** : jamais deux fois la même question. `min(nombre, len(mots))` évite une erreur si on demande plus de questions qu'il n'y a de mots.
|
||
- Le test `test_fichier_de_mots_valide` vérifie les **données** livrées avec le programme : un JSON cassé par une modification manuelle serait détecté immédiatement.
|
||
- Ce fichier unique est un point de départ ; le jalon suivant consiste à le répartir en modules dans `src/vocabulo/` comme le projet fil rouge.
|
||
|
||
</details>
|
||
|
||
<details>
|
||
<summary>Corrigé du mini-projet</summary>
|
||
|
||
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`) :
|
||
|
||
```markdown
|
||
## 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.
|
||
```
|
||
|
||
Si tu as coché toutes les cases : **félicitations, tu as mené un projet Python de A à Z.** Tu n'es plus un grand débutant : tu es un développeur qui apprend, comme tous les développeurs. 🎓
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
## 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 `print` ni `input` : 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/` et `tests/`.
|
||
- [ ] 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](aide-memoire.md) sous la main, pioche dans les [ressources](ressources.md) 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 ! 🐍
|
||
|
||
---
|
||
|
||
[← Chapitre précédent](14-specialisations.md) | [Sommaire](README.md) | [Aide-mémoire →](aide-memoire.md)
|