2274 lines
102 KiB
Markdown
2274 lines
102 KiB
Markdown
# Chapitre 14 — Les spécialisations : automatisation, web, data, IA et jeux
|
||
|
||
> **⏱️ Durée estimée** : 8 à 12 heures pour tout parcourir (tu peux aussi ne lire en détail que les domaines qui t'attirent)
|
||
>
|
||
> **📋 Prérequis** : avoir terminé le [Chapitre 13 — Tests avec pytest et bonnes pratiques](13-tests-pytest-bonnes-pratiques.md), savoir créer un venv et installer des paquets ([Chapitre 12](12-outils-venv-pip-git.md)), être à l'aise avec les fichiers ([Chapitre 07](07-fichiers-csv-json.md)) et les classes ([Chapitre 10](10-poo-classes-objets.md)).
|
||
>
|
||
> **🎯 Objectifs** — À la fin de ce chapitre, tu sauras…
|
||
>
|
||
> - **automatiser** des tâches répétitives sur tes fichiers avec `pathlib`, `shutil` et `argparse` ;
|
||
> - créer une petite **application web** avec Flask et une **API** avec FastAPI ;
|
||
> - charger, filtrer, résumer des **données** avec pandas et les représenter avec matplotlib ;
|
||
> - entraîner et évaluer un premier modèle de **machine learning** avec scikit-learn ;
|
||
> - programmer la **boucle de jeu** d'un mini-jeu avec pygame ;
|
||
> - choisir une spécialisation et suivre un **parcours d'apprentissage** pour progresser.
|
||
|
||
---
|
||
|
||
## Cours
|
||
|
||
Félicitations ! 🎉 Tu maîtrises maintenant les bases de Python : variables, conditions, boucles, structures de données, fonctions, fichiers, exceptions, modules, classes, tests et outils. C'est comme si tu avais appris à lire, écrire et compter.
|
||
|
||
La question devient : **qu'est-ce que tu veux construire ?** Python est un couteau suisse. Le même langage sert à ranger tes photos, faire tourner un site web, analyser des millions de lignes de données, entraîner une intelligence artificielle ou créer un jeu. Chaque domaine a ses propres **bibliothèques**, ses habitudes et ses outils.
|
||
|
||
Ce chapitre est une **visite guidée** de cinq grandes spécialisations. Pour chacune :
|
||
|
||
1. à quoi ça sert, concrètement ;
|
||
2. les bibliothèques clés ;
|
||
3. un **exemple complet et fonctionnel** à recopier et lancer ;
|
||
4. un mini-parcours pour aller plus loin.
|
||
|
||
> 💡 **Conseil d'organisation** : crée un dossier `specialisations/` avec **un venv par domaine** (`automatisation/`, `web/`, `data/`, `ia/`, `jeux/`). Les bibliothèques de data et d'IA sont volumineuses : inutile de les installer là où tu fais du web.
|
||
|
||
```bash
|
||
mkdir -p specialisations/web && cd specialisations/web
|
||
python3 -m venv .venv # Windows : py -m venv .venv
|
||
source .venv/bin/activate # Windows : .venv\Scripts\Activate.ps1
|
||
```
|
||
|
||
### 1. Automatisation : faire travailler l'ordinateur à ta place
|
||
|
||
#### 1.1 À quoi ça sert ?
|
||
|
||
Toute tâche **répétitive** sur un ordinateur peut probablement être automatisée : renommer 300 photos, trier un dossier « Téléchargements » en désordre, fusionner des fichiers Excel chaque lundi, envoyer un e-mail de rappel, récupérer une information sur un site web tous les matins, sauvegarder un dossier…
|
||
|
||
C'est souvent **la spécialisation la plus rentable pour un débutant** : quelques dizaines de lignes de Python te font gagner des heures, dès maintenant, sans rien installer d'autre que Python.
|
||
|
||
> 🧠 Règle empirique : si tu fais la même manipulation plus de trois fois, ou si elle prend plus de dix minutes, écris un script.
|
||
|
||
#### 1.2 Bibliothèques clés
|
||
|
||
| Bibliothèque | Rôle | Installation |
|
||
|---|---|---|
|
||
| `pathlib`, `shutil`, `os` | parcourir, copier, déplacer, renommer des fichiers | incluses dans Python |
|
||
| `argparse` | créer des commandes avec options (`--aide`, `--executer`…) | incluse |
|
||
| `csv`, `json`, `zipfile` | lire/écrire des formats courants, compresser | incluses |
|
||
| `datetime`, `time`, `sched` | dates, attentes, planification simple | incluses |
|
||
| `subprocess` | lancer d'autres programmes | incluse |
|
||
| `requests` | télécharger des pages web, appeler des API | `pip install requests` |
|
||
| `beautifulsoup4` | extraire des informations d'une page HTML (*scraping*) | `pip install beautifulsoup4` |
|
||
| `openpyxl` | lire/écrire des fichiers Excel `.xlsx` | `pip install openpyxl` |
|
||
| `pypdf` | lire, fusionner, découper des PDF | `pip install pypdf` |
|
||
| `Pillow` | redimensionner, convertir des images | `pip install pillow` |
|
||
| `schedule` | lancer une fonction à heure fixe | `pip install schedule` |
|
||
| `playwright` / `selenium` | piloter un vrai navigateur | `pip install playwright` |
|
||
|
||
#### 1.3 Exemple complet : trier un dossier par type de fichier
|
||
|
||
Ce script range chaque fichier d'un dossier dans un sous-dossier selon son extension (`Images/`, `Documents/`, `Audio/`…). Par sécurité, il fonctionne d'abord en **mode simulation** : il affiche ce qu'il ferait sans rien toucher. Il faut ajouter `--executer` pour qu'il déplace vraiment les fichiers.
|
||
|
||
Enregistre-le sous `trier_fichiers.py` :
|
||
|
||
```python
|
||
"""Range les fichiers d'un dossier dans des sous-dossiers selon leur type.
|
||
|
||
Usage :
|
||
python trier_fichiers.py chemin/du/dossier # simulation
|
||
python trier_fichiers.py chemin/du/dossier --executer # pour de vrai
|
||
"""
|
||
|
||
import argparse
|
||
import shutil
|
||
from pathlib import Path
|
||
|
||
CATEGORIES = {
|
||
"Images": {".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"},
|
||
"Documents": {".pdf", ".docx", ".odt", ".txt", ".md", ".xlsx", ".csv"},
|
||
"Audio": {".mp3", ".wav", ".flac", ".ogg"},
|
||
"Videos": {".mp4", ".mkv", ".avi", ".mov"},
|
||
"Archives": {".zip", ".tar", ".gz", ".7z", ".rar"},
|
||
"Code": {".py", ".js", ".html", ".css", ".json"},
|
||
}
|
||
|
||
|
||
def trouver_categorie(fichier: Path) -> str:
|
||
"""Renvoie le nom de la catégorie d'un fichier d'après son extension."""
|
||
extension = fichier.suffix.lower()
|
||
for categorie, extensions in CATEGORIES.items():
|
||
if extension in extensions:
|
||
return categorie
|
||
return "Autres"
|
||
|
||
|
||
def destination_libre(destination: Path) -> Path:
|
||
"""Ajoute un numéro au nom si un fichier du même nom existe déjà."""
|
||
if not destination.exists():
|
||
return destination
|
||
compteur = 1
|
||
while True:
|
||
candidat = destination.with_stem(f"{destination.stem}_{compteur}")
|
||
if not candidat.exists():
|
||
return candidat
|
||
compteur += 1
|
||
|
||
|
||
def trier_dossier(dossier: Path, executer: bool = False) -> dict[str, int]:
|
||
"""Trie les fichiers de dossier. Renvoie le nombre de fichiers par catégorie."""
|
||
bilan: dict[str, int] = {}
|
||
for fichier in sorted(dossier.iterdir()):
|
||
if not fichier.is_file() or fichier.name.startswith("."):
|
||
continue # on ignore les sous-dossiers et les fichiers cachés
|
||
categorie = trouver_categorie(fichier)
|
||
destination = destination_libre(dossier / categorie / fichier.name)
|
||
if executer:
|
||
destination.parent.mkdir(exist_ok=True)
|
||
shutil.move(fichier, destination)
|
||
action = "Déplacé" if executer else "[simulation]"
|
||
print(f"{action} {fichier.name} → {categorie}/")
|
||
bilan[categorie] = bilan.get(categorie, 0) + 1
|
||
return bilan
|
||
|
||
|
||
def main() -> None:
|
||
analyseur = argparse.ArgumentParser(description="Range un dossier par type.")
|
||
analyseur.add_argument("dossier", type=Path, help="dossier à ranger")
|
||
analyseur.add_argument(
|
||
"--executer", action="store_true", help="déplace vraiment les fichiers"
|
||
)
|
||
arguments = analyseur.parse_args()
|
||
|
||
if not arguments.dossier.is_dir():
|
||
analyseur.error(f"{arguments.dossier} n'est pas un dossier.")
|
||
|
||
bilan = trier_dossier(arguments.dossier, arguments.executer)
|
||
print("\nBilan :")
|
||
for categorie, nombre in sorted(bilan.items()):
|
||
print(f" {categorie:<10} {nombre} fichier(s)")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|
||
```
|
||
|
||
**Essaie-le sur un dossier de test**, jamais directement sur tes vrais fichiers la première fois :
|
||
|
||
```bash
|
||
mkdir bazar
|
||
# crée quelques fichiers vides pour tester (macOS / Linux) :
|
||
touch bazar/vacances.JPG bazar/cv.pdf bazar/chanson.mp3 bazar/script.py bazar/archive.zip bazar/notes.txt bazar/mystere.xyz
|
||
```
|
||
|
||
```powershell
|
||
# Windows PowerShell :
|
||
mkdir bazar
|
||
"vacances.JPG","cv.pdf","chanson.mp3","script.py","archive.zip","notes.txt","mystere.xyz" | ForEach-Object { New-Item "bazar\$_" }
|
||
```
|
||
|
||
```bash
|
||
python trier_fichiers.py bazar # simulation
|
||
python trier_fichiers.py bazar --executer # pour de vrai
|
||
python trier_fichiers.py --help # aide générée automatiquement par argparse
|
||
```
|
||
|
||
```text
|
||
[simulation] archive.zip → Archives/
|
||
[simulation] chanson.mp3 → Audio/
|
||
[simulation] cv.pdf → Documents/
|
||
[simulation] mystere.xyz → Autres/
|
||
[simulation] notes.txt → Documents/
|
||
[simulation] script.py → Code/
|
||
[simulation] vacances.JPG → Images/
|
||
|
||
Bilan :
|
||
Archives 1 fichier(s)
|
||
Audio 1 fichier(s)
|
||
Autres 1 fichier(s)
|
||
Code 1 fichier(s)
|
||
Documents 2 fichier(s)
|
||
Images 1 fichier(s)
|
||
```
|
||
|
||
**Ce qu'il faut remarquer :**
|
||
|
||
- `CATEGORIES` est un dictionnaire d'**ensembles** ([chapitre 04](04-structures-de-donnees.md)) : la recherche `extension in extensions` est instantanée. Pour ajouter un type, tu modifies **les données**, pas le code.
|
||
- `fichier.suffix.lower()` : `.JPG` et `.jpg` sont traités pareil.
|
||
- `destination_libre` évite d'**écraser** un fichier existant (`photo.jpg` devient `photo_1.jpg`). Avec l'automatisation, une erreur se répète sur des centaines de fichiers : sois prudent !
|
||
- `argparse` transforme ton script en véritable **outil en ligne de commande**, avec une aide `--help` gratuite.
|
||
- Les fonctions `trouver_categorie` et `trier_dossier` sont faciles à tester avec pytest et `tmp_path` ([chapitre 13](13-tests-pytest-bonnes-pratiques.md)).
|
||
|
||
> ⚠️ **Les trois règles de l'automatisation sereine** : 1) toujours un mode simulation ; 2) tester sur une copie ; 3) ne jamais supprimer quand on peut déplacer.
|
||
|
||
#### 1.4 Aller plus loin en automatisation
|
||
|
||
1. **Fichiers** : renommage en lot, sauvegarde datée d'un dossier dans un `.zip` (`shutil.make_archive`), recherche de doublons (en comparant des empreintes avec `hashlib`).
|
||
2. **Bureautique** : fusionner des fichiers Excel avec `openpyxl` (ou pandas), assembler des PDF avec `pypdf`, redimensionner des photos avec `Pillow`.
|
||
3. **Web** : télécharger des données avec `requests`, interroger des API publiques (météo, transports…), extraire des informations avec `BeautifulSoup`. Respecte les conditions d'utilisation des sites et ne les surcharge pas de requêtes.
|
||
4. **Planification** : lancer tes scripts automatiquement avec le Planificateur de tâches (Windows), `cron` (macOS/Linux) ou la bibliothèque `schedule`.
|
||
5. **Lecture recommandée** : le livre *Automate the Boring Stuff with Python* d'Al Sweigart (gratuit en ligne, en anglais) est la référence du domaine.
|
||
|
||
### 2. Web : créer des sites et des API
|
||
|
||
#### 2.1 À quoi ça sert ?
|
||
|
||
Quand tu ouvres une page web, ton navigateur (le **client**) envoie une **requête HTTP** à un **serveur**, qui renvoie une **réponse** : une page HTML, une image, ou des données au format JSON.
|
||
|
||
```text
|
||
Navigateur Serveur Python
|
||
───────── ──────────────
|
||
GET /api/taches ───────▶ cherche les tâches
|
||
◀─────── 200 OK [{"id": 1, "titre": "..."}]
|
||
```
|
||
|
||
Python est très utilisé **côté serveur** (le *back-end*) : Instagram, Pinterest ou de nombreux services de grandes entreprises reposent sur Python. Deux types de projets :
|
||
|
||
- un **site web** : le serveur renvoie des pages HTML que l'humain lit dans son navigateur ;
|
||
- une **API** (*Application Programming Interface*) : le serveur renvoie des **données** (JSON) que d'autres programmes utilisent (une application mobile, un site en JavaScript, un autre script Python…).
|
||
|
||
Vocabulaire indispensable :
|
||
|
||
| Terme | Signification |
|
||
|---|---|
|
||
| **Route** (ou *endpoint*) | une adresse du site, comme `/`, `/contact`, `/api/taches/3` |
|
||
| **Méthode HTTP** | l'intention : `GET` (lire), `POST` (créer), `PUT`/`PATCH` (modifier), `DELETE` (supprimer) |
|
||
| **Code de statut** | le résultat : `200` OK, `201` créé, `404` introuvable, `422` données invalides, `500` erreur du serveur |
|
||
| **JSON** | le format de données du web, très proche des dictionnaires Python ([chapitre 07](07-fichiers-csv-json.md)) |
|
||
| **Template** | un modèle HTML avec des « trous » remplis par Python |
|
||
|
||
#### 2.2 Bibliothèques clés
|
||
|
||
| Framework | Points forts | Pour qui ? |
|
||
|---|---|---|
|
||
| **Flask** | minimaliste, simple, très bien documenté | premier site, petits projets, apprendre les bases |
|
||
| **FastAPI** | moderne, rapide, validation automatique, documentation d'API générée | API, projets qui utilisent les annotations de type ([chapitre 11](11-poo-avancee-heritage-typage.md)) |
|
||
| **Django** | « piles incluses » : base de données, comptes utilisateurs, interface d'administration | gros sites complets |
|
||
|
||
Autour : `Jinja2` (templates, utilisé par Flask), `SQLAlchemy` ou l'ORM de Django (bases de données), `pydantic` (validation, utilisé par FastAPI), `requests`/`httpx` (appeler des API).
|
||
|
||
#### 2.3 Exemple complet 1 : un site de citations avec Flask
|
||
|
||
```bash
|
||
pip install flask
|
||
```
|
||
|
||
Enregistre `app_flask.py` :
|
||
|
||
```python
|
||
"""Une mini application web de citations avec Flask."""
|
||
|
||
import random
|
||
|
||
from flask import Flask, jsonify, render_template_string, request
|
||
|
||
app = Flask(__name__)
|
||
|
||
CITATIONS = [
|
||
{"auteur": "Victor Hugo", "texte": "La musique, c'est du bruit qui pense."},
|
||
{
|
||
"auteur": "Marie Curie",
|
||
"texte": "Rien dans la vie n'est à craindre, tout est à comprendre.",
|
||
},
|
||
{
|
||
"auteur": "Albert Camus",
|
||
"texte": "Au milieu de l'hiver, j'ai découvert en moi un invincible été.",
|
||
},
|
||
]
|
||
|
||
PAGE = """
|
||
<!doctype html>
|
||
<html lang="fr">
|
||
<head><meta charset="utf-8"><title>Citation du jour</title></head>
|
||
<body style="font-family: sans-serif; max-width: 40rem; margin: 3rem auto;">
|
||
<h1>💬 Citation du jour</h1>
|
||
<blockquote>« {{ citation.texte }} »</blockquote>
|
||
<p>— <em>{{ citation.auteur }}</em></p>
|
||
<p><a href="/">Une autre !</a> · <a href="/api/citations">Version JSON</a></p>
|
||
<h2>Proposer une citation</h2>
|
||
<form method="post" action="/ajouter">
|
||
<input name="auteur" placeholder="Auteur" required>
|
||
<input name="texte" placeholder="Citation" required size="40">
|
||
<button type="submit">Ajouter</button>
|
||
</form>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
@app.route("/")
|
||
def accueil():
|
||
return render_template_string(PAGE, citation=random.choice(CITATIONS))
|
||
|
||
|
||
@app.route("/api/citations")
|
||
def api_citations():
|
||
return jsonify(CITATIONS)
|
||
|
||
|
||
@app.route("/ajouter", methods=["POST"])
|
||
def ajouter():
|
||
CITATIONS.append({"auteur": request.form["auteur"], "texte": request.form["texte"]})
|
||
return render_template_string(PAGE, citation=CITATIONS[-1])
|
||
|
||
|
||
if __name__ == "__main__":
|
||
app.run(debug=True)
|
||
```
|
||
|
||
Lance le serveur :
|
||
|
||
```bash
|
||
python app_flask.py
|
||
```
|
||
|
||
```text
|
||
* Running on http://127.0.0.1:5000
|
||
* Debugger is active!
|
||
```
|
||
|
||
Ouvre **http://127.0.0.1:5000** dans ton navigateur. Rafraîchis : la citation change. Clique sur « Version JSON » : tu vois les données brutes. Ajoute une citation avec le formulaire. Pour arrêter le serveur : `Ctrl+C` dans le terminal.
|
||
|
||
**Décryptage :**
|
||
|
||
- `@app.route("/")` est un **décorateur** : il associe l'adresse `/` à la fonction `accueil`. Quand le navigateur demande `/`, Flask appelle `accueil()` et renvoie ce qu'elle retourne.
|
||
- `{{ citation.texte }}` est un « trou » du **template** Jinja2, rempli par `render_template_string(PAGE, citation=...)`. Jinja2 **échappe** automatiquement les caractères spéciaux : si quelqu'un tape `<script>` dans le formulaire, il s'affiche comme du texte au lieu de s'exécuter. C'est une protection de sécurité importante.
|
||
- `methods=["POST"]` : la route `/ajouter` n'accepte que l'envoi de formulaire. `request.form["auteur"]` lit le champ `name="auteur"`.
|
||
- `jsonify(...)` transforme une liste/dictionnaire Python en réponse JSON.
|
||
- `debug=True` recharge le serveur à chaque modification du code et affiche des erreurs détaillées. **Uniquement en développement**, jamais sur un vrai serveur public.
|
||
- Les citations ajoutées sont perdues à l'arrêt du serveur (elles sont en mémoire). Pour les garder, il faudrait un fichier JSON ou une base de données.
|
||
|
||
> 💡 Dans un vrai projet Flask, les templates sont dans des fichiers séparés (`templates/accueil.html`) et on utilise `render_template("accueil.html", ...)`. On a tout mis dans un seul fichier pour que l'exemple soit facile à copier.
|
||
|
||
**Tester une application Flask** : Flask fournit un faux navigateur, `app.test_client()`, parfait avec pytest :
|
||
|
||
```python
|
||
from app_flask import app
|
||
|
||
|
||
def test_accueil():
|
||
client = app.test_client()
|
||
reponse = client.get("/")
|
||
assert reponse.status_code == 200
|
||
assert "Citation du jour" in reponse.get_data(as_text=True)
|
||
```
|
||
|
||
#### 2.4 Exemple complet 2 : une API de tâches avec FastAPI
|
||
|
||
```bash
|
||
pip install "fastapi[standard]"
|
||
```
|
||
|
||
(Les guillemets sont nécessaires à cause des crochets ; `[standard]` installe aussi le serveur `uvicorn` et la commande `fastapi`.)
|
||
|
||
Enregistre `api_taches.py` :
|
||
|
||
```python
|
||
"""Une mini API de liste de tâches avec FastAPI."""
|
||
|
||
from fastapi import FastAPI, HTTPException
|
||
from pydantic import BaseModel
|
||
|
||
app = FastAPI(title="API des tâches")
|
||
|
||
|
||
class NouvelleTache(BaseModel):
|
||
titre: str
|
||
priorite: int = 1
|
||
|
||
|
||
class Tache(NouvelleTache):
|
||
id: int
|
||
terminee: bool = False
|
||
|
||
|
||
taches: dict[int, Tache] = {}
|
||
prochain_id = 1
|
||
|
||
|
||
@app.get("/")
|
||
def accueil() -> dict[str, str]:
|
||
return {"message": "Bienvenue ! Documentation sur /docs"}
|
||
|
||
|
||
@app.get("/taches")
|
||
def lister_taches(terminee: bool | None = None) -> list[Tache]:
|
||
resultat = list(taches.values())
|
||
if terminee is not None:
|
||
resultat = [t for t in resultat if t.terminee == terminee]
|
||
return resultat
|
||
|
||
|
||
@app.post("/taches", status_code=201)
|
||
def creer_tache(nouvelle: NouvelleTache) -> Tache:
|
||
global prochain_id
|
||
tache = Tache(id=prochain_id, **nouvelle.model_dump())
|
||
taches[tache.id] = tache
|
||
prochain_id += 1
|
||
return tache
|
||
|
||
|
||
@app.get("/taches/{tache_id}")
|
||
def lire_tache(tache_id: int) -> Tache:
|
||
if tache_id not in taches:
|
||
raise HTTPException(status_code=404, detail="Tâche introuvable")
|
||
return taches[tache_id]
|
||
|
||
|
||
@app.patch("/taches/{tache_id}/terminer")
|
||
def terminer_tache(tache_id: int) -> Tache:
|
||
tache = lire_tache(tache_id)
|
||
tache.terminee = True
|
||
return tache
|
||
|
||
|
||
@app.delete("/taches/{tache_id}", status_code=204)
|
||
def supprimer_tache(tache_id: int) -> None:
|
||
if taches.pop(tache_id, None) is None:
|
||
raise HTTPException(status_code=404, detail="Tâche introuvable")
|
||
```
|
||
|
||
Lance le serveur de développement :
|
||
|
||
```bash
|
||
fastapi dev api_taches.py
|
||
# ou, équivalent : uvicorn api_taches:app --reload
|
||
```
|
||
|
||
Puis ouvre **http://127.0.0.1:8000/docs** : FastAPI a généré **tout seul** une documentation interactive de ton API ! Clique sur `POST /taches` → *Try it out* → modifie le JSON → *Execute*. Tu peux tester chaque route sans écrire une ligne de code client. 🤩
|
||
|
||
Tu peux aussi l'interroger depuis un terminal avec `curl` :
|
||
|
||
```bash
|
||
curl -X POST http://127.0.0.1:8000/taches -H "Content-Type: application/json" -d '{"titre": "Tester curl"}'
|
||
# {"titre":"Tester curl","priorite":1,"id":1,"terminee":false}
|
||
curl http://127.0.0.1:8000/taches
|
||
```
|
||
|
||
```powershell
|
||
# Windows PowerShell :
|
||
Invoke-RestMethod -Method Post -Uri http://127.0.0.1:8000/taches -ContentType "application/json" -Body '{"titre": "Tester"}'
|
||
Invoke-RestMethod http://127.0.0.1:8000/taches
|
||
```
|
||
|
||
**Décryptage :**
|
||
|
||
- Les classes `BaseModel` de **pydantic** décrivent la forme des données. Si un client envoie `{"priorite": "haute"}` (sans titre, et un texte au lieu d'un nombre), FastAPI répond automatiquement `422` avec un message précis : *Field required*, *Input should be a valid integer*. Tu n'écris aucun `if` de validation.
|
||
- Les **annotations de type** ([chapitre 11](11-poo-avancee-heritage-typage.md)) ne sont plus décoratives : `tache_id: int` convertit et vérifie le paramètre de l'URL ; `terminee: bool | None = None` crée un paramètre facultatif `?terminee=true`.
|
||
- `-> Tache` indique le format de la réponse, utilisé pour la documentation et le filtrage des données.
|
||
- `HTTPException(status_code=404, ...)` renvoie une erreur HTTP propre.
|
||
- `Tache(NouvelleTache)` : de l'**héritage** ! Une tâche, c'est une nouvelle tâche avec un `id` et un état en plus.
|
||
|
||
**Tester une API FastAPI** avec pytest (`pip install httpx` si besoin) :
|
||
|
||
```python
|
||
from fastapi.testclient import TestClient
|
||
|
||
from api_taches import app
|
||
|
||
client = TestClient(app)
|
||
|
||
|
||
def test_scenario_complet():
|
||
reponse = client.post("/taches", json={"titre": "Apprendre FastAPI", "priorite": 2})
|
||
assert reponse.status_code == 201
|
||
assert reponse.json()["id"] == 1
|
||
|
||
assert client.patch("/taches/1/terminer").json()["terminee"] is True
|
||
assert client.delete("/taches/1").status_code == 204
|
||
assert client.get("/taches/1").status_code == 404
|
||
|
||
|
||
def test_donnees_invalides():
|
||
reponse = client.post("/taches", json={"priorite": "haute"})
|
||
assert reponse.status_code == 422
|
||
```
|
||
|
||
#### 2.5 Aller plus loin en web
|
||
|
||
1. **Les bases du web** : HTML et CSS (indispensables, même côté serveur ; MDN Web Docs est la référence, en français), puis un peu de JavaScript.
|
||
2. **Flask** : le tutoriel officiel « Flaskr » (un blog avec base de données et comptes), les templates dans des fichiers, les formulaires, les sessions.
|
||
3. **Bases de données** : SQL avec le module `sqlite3` (inclus dans Python), puis SQLAlchemy.
|
||
4. **FastAPI** : le tutoriel officiel (excellent, disponible en partie en français), l'authentification, les bases de données avec SQLModel.
|
||
5. **Django** : le tutoriel officiel « Écriture de votre première application Django » (traduit en français) ; le *Django Girls Tutorial* (en français) est parfait pour débuter.
|
||
6. **Mettre en ligne** : héberger ton application sur un service comme PythonAnywhere, Render ou Fly.io ; apprendre les bases de la sécurité (mots de passe hachés, variables d'environnement pour les secrets, HTTPS).
|
||
|
||
### 3. Data : analyser et visualiser des données
|
||
|
||
#### 3.1 À quoi ça sert ?
|
||
|
||
Les entreprises, les laboratoires, les associations et les administrations croulent sous les données : ventes, mesures de capteurs, résultats d'enquêtes, statistiques publiques… L'**analyse de données** consiste à les **nettoyer**, les **explorer**, les **résumer** et les **visualiser** pour répondre à des questions : *quel produit se vend le mieux ? les ventes augmentent-elles ? y a-t-il une différence entre les villes ?*
|
||
|
||
Python est **le** langage de référence de la data, grâce à un écosystème exceptionnel. C'est aussi la porte d'entrée vers l'IA (section 4).
|
||
|
||
#### 3.2 Bibliothèques clés
|
||
|
||
| Bibliothèque | Rôle |
|
||
|---|---|
|
||
| **pandas** | le tableur de Python : charger (CSV, Excel, JSON, SQL), filtrer, grouper, calculer |
|
||
| **NumPy** | calcul numérique rapide sur des tableaux de nombres (la fondation de tout l'écosystème) |
|
||
| **matplotlib** | la bibliothèque de graphiques historique, très complète |
|
||
| **seaborn** | graphiques statistiques élégants, basés sur matplotlib |
|
||
| **plotly** | graphiques interactifs (zoom, survol) dans le navigateur |
|
||
| **Jupyter** (JupyterLab, ou les notebooks de VS Code) | carnets interactifs : code, résultats et graphiques mélangés, idéal pour explorer |
|
||
| **polars** | alternative moderne et très rapide à pandas |
|
||
|
||
```bash
|
||
pip install pandas matplotlib
|
||
```
|
||
|
||
L'objet central de pandas est le **DataFrame** : un tableau avec des lignes et des **colonnes nommées**, comme une feuille de calcul. Chaque colonne est une **Series**.
|
||
|
||
#### 3.3 Exemple complet : analyser les ventes d'une boulangerie
|
||
|
||
Crée d'abord le fichier de données `ventes.csv` (copie-colle ce contenu dans VS Code et enregistre-le en UTF-8) :
|
||
|
||
```text
|
||
date,produit,categorie,quantite,prix_unitaire,ville
|
||
2026-01-05,Croissant,Viennoiserie,120,1.10,Lyon
|
||
2026-01-05,Baguette,Pain,200,1.20,Lyon
|
||
2026-01-12,Pain au chocolat,Viennoiserie,95,1.30,Paris
|
||
2026-01-19,Baguette,Pain,260,1.20,Paris
|
||
2026-01-26,Éclair,Pâtisserie,40,2.80,Lyon
|
||
2026-02-02,Croissant,Viennoiserie,140,1.10,Paris
|
||
2026-02-09,Baguette,Pain,230,1.20,Lyon
|
||
2026-02-16,Tarte aux pommes,Pâtisserie,25,3.50,Paris
|
||
2026-02-23,Pain au chocolat,Viennoiserie,110,1.30,Lyon
|
||
2026-03-02,Éclair,Pâtisserie,55,2.80,Paris
|
||
2026-03-09,Baguette,Pain,280,1.20,Paris
|
||
2026-03-16,Croissant,Viennoiserie,150,1.10,Lyon
|
||
2026-03-23,Pain complet,Pain,70,2.10,Lyon
|
||
2026-03-30,Tarte aux pommes,Pâtisserie,30,3.50,Lyon
|
||
2026-04-06,Pain au chocolat,Viennoiserie,130,1.30,Paris
|
||
2026-04-13,Baguette,Pain,300,1.20,Lyon
|
||
```
|
||
|
||
Puis `analyse_ventes.py`, dans le même dossier :
|
||
|
||
```python
|
||
"""Analyse des ventes d'une boulangerie avec pandas et matplotlib."""
|
||
|
||
import matplotlib.pyplot as plt
|
||
import pandas as pd
|
||
|
||
# 1. Charger les données (la colonne date est convertie en vraies dates)
|
||
ventes = pd.read_csv("ventes.csv", parse_dates=["date"])
|
||
|
||
# 2. Découvrir le tableau
|
||
print(ventes.head()) # les 5 premières lignes
|
||
print(ventes.shape) # (nombre de lignes, nombre de colonnes)
|
||
ventes.info() # types des colonnes, valeurs manquantes
|
||
|
||
# 3. Créer une nouvelle colonne calculée
|
||
ventes["chiffre_affaires"] = ventes["quantite"] * ventes["prix_unitaire"]
|
||
|
||
# 4. Statistiques simples
|
||
print(f"\nChiffre d'affaires total : {ventes['chiffre_affaires'].sum():.2f} €")
|
||
print(f"Quantité moyenne par vente : {ventes['quantite'].mean():.1f}")
|
||
|
||
# 5. Filtrer : seulement Paris
|
||
paris = ventes[ventes["ville"] == "Paris"]
|
||
print(f"Nombre de ventes à Paris : {len(paris)}")
|
||
|
||
# 6. Regrouper : chiffre d'affaires par catégorie
|
||
par_categorie = (
|
||
ventes.groupby("categorie")["chiffre_affaires"].sum().sort_values(ascending=False)
|
||
)
|
||
print("\nChiffre d'affaires par catégorie :")
|
||
print(par_categorie)
|
||
|
||
# 7. Regrouper par mois et par ville (tableau croisé)
|
||
ventes["mois"] = ventes["date"].dt.to_period("M")
|
||
par_mois = ventes.pivot_table(
|
||
index="mois", columns="ville", values="chiffre_affaires", aggfunc="sum"
|
||
)
|
||
print("\nChiffre d'affaires par mois et par ville :")
|
||
print(par_mois)
|
||
|
||
# 8. Visualiser : deux graphiques côte à côte
|
||
figure, (axe1, axe2) = plt.subplots(1, 2, figsize=(12, 5))
|
||
|
||
par_categorie.plot(kind="bar", ax=axe1, color="#4C72B0")
|
||
axe1.set_title("Chiffre d'affaires par catégorie")
|
||
axe1.set_xlabel("")
|
||
axe1.set_ylabel("Euros")
|
||
axe1.tick_params(axis="x", rotation=0)
|
||
|
||
par_mois.plot(kind="line", marker="o", ax=axe2)
|
||
axe2.set_title("Évolution mensuelle par ville")
|
||
axe2.set_xlabel("Mois")
|
||
axe2.set_ylabel("Euros")
|
||
|
||
figure.tight_layout()
|
||
figure.savefig("ventes.png", dpi=100)
|
||
print("\nGraphique enregistré dans ventes.png")
|
||
plt.show()
|
||
```
|
||
|
||
```bash
|
||
python analyse_ventes.py
|
||
```
|
||
|
||
Extrait de la sortie :
|
||
|
||
```text
|
||
date produit categorie quantite prix_unitaire ville
|
||
0 2026-01-05 Croissant Viennoiserie 120 1.1 Lyon
|
||
1 2026-01-05 Baguette Pain 200 1.2 Lyon
|
||
2 2026-01-12 Pain au chocolat Viennoiserie 95 1.3 Paris
|
||
...
|
||
(16, 6)
|
||
...
|
||
Chiffre d'affaires total : 3016.00 €
|
||
Quantité moyenne par vente : 139.7
|
||
Nombre de ventes à Paris : 7
|
||
|
||
Chiffre d'affaires par catégorie :
|
||
categorie
|
||
Pain 1671.0
|
||
Viennoiserie 886.5
|
||
Pâtisserie 458.5
|
||
Name: chiffre_affaires, dtype: float64
|
||
|
||
Chiffre d'affaires par mois et par ville :
|
||
ville Lyon Paris
|
||
mois
|
||
2026-01 484.0 435.5
|
||
2026-02 419.0 241.5
|
||
2026-03 417.0 490.0
|
||
2026-04 360.0 169.0
|
||
|
||
Graphique enregistré dans ventes.png
|
||
```
|
||
|
||
Une fenêtre s'ouvre avec deux graphiques : un diagramme en barres (le pain domine largement) et deux courbes mensuelles (Lyon est plus régulière que Paris). L'image est aussi enregistrée dans `ventes.png`.
|
||
|
||
**Décryptage des opérations pandas essentielles :**
|
||
|
||
| Code | Effet | Équivalent « Python pur » |
|
||
|---|---|---|
|
||
| `ventes["quantite"]` | une colonne (Series) | `[ligne["quantite"] for ligne in lignes]` |
|
||
| `ventes["quantite"] * ventes["prix_unitaire"]` | calcul **ligne par ligne**, sans boucle | une boucle `for` |
|
||
| `ventes[ventes["ville"] == "Paris"]` | filtre les lignes | compréhension avec `if` |
|
||
| `ventes.groupby("categorie")[...].sum()` | somme par groupe | dictionnaire d'accumulation |
|
||
| `.sort_values(ascending=False)` | trie | `sorted(..., reverse=True)` |
|
||
| `pivot_table(index=..., columns=..., values=..., aggfunc=...)` | tableau croisé dynamique | double dictionnaire |
|
||
| `ventes["date"].dt.to_period("M")` | extrait le mois d'une date | `date.strftime("%Y-%m")` |
|
||
|
||
> 💡 **Combiner des conditions** : avec pandas, on n'utilise pas `and`/`or` mais `&`/`|`, et chaque condition entre parenthèses : `ventes[(ventes["ville"] == "Lyon") & (ventes["quantite"] > 100)]`.
|
||
|
||
> 💡 **Jupyter** : pour explorer des données, les *notebooks* sont bien plus confortables qu'un script : tu exécutes cellule par cellule et les tableaux s'affichent joliment. Dans VS Code, crée un fichier `.ipynb` (installe le paquet `ipykernel` dans ton venv), ou installe JupyterLab (`pip install jupyterlab` puis `jupyter lab`).
|
||
|
||
#### 3.4 Aller plus loin en data
|
||
|
||
1. **pandas en profondeur** : le guide « 10 minutes to pandas » de la documentation officielle, puis la gestion des valeurs manquantes (`isna`, `fillna`, `dropna`), les jointures (`merge`), les dates.
|
||
2. **Vraies données** : explore les jeux de données ouverts de [data.gouv.fr](https://www.data.gouv.fr) (au format CSV, en français), l'INSEE, ou Kaggle.
|
||
3. **Visualisation** : seaborn pour les graphiques statistiques, plotly pour l'interactif ; apprends à choisir le bon graphique (barres pour comparer, courbe pour une évolution, nuage de points pour une relation).
|
||
4. **Statistiques** : moyenne, médiane, écart-type, corrélation… La data sans statistiques, c'est comme la cuisine sans goûter.
|
||
5. **SQL** : le langage des bases de données, incontournable pour tout poste en data.
|
||
6. **Tableaux de bord** : Streamlit permet de transformer un script d'analyse en application web interactive en quelques lignes.
|
||
|
||
### 4. Intelligence artificielle et machine learning
|
||
|
||
#### 4.1 À quoi ça sert ?
|
||
|
||
Le **machine learning** (apprentissage automatique) consiste à laisser un programme **apprendre des règles à partir d'exemples**, au lieu de les écrire à la main.
|
||
|
||
Analogie : pour apprendre à un enfant à reconnaître les chiens et les chats, tu ne lui donnes pas une liste de règles (« si les oreilles sont pointues et que… »). Tu lui montres **des centaines d'exemples** étiquetés, et il finit par généraliser. Le machine learning fait pareil avec des données.
|
||
|
||
Exemples d'applications : filtre anti-spam, recommandation de films, détection de fraude bancaire, reconnaissance d'images, traduction automatique, prévision de ventes… et les grands modèles de langage (les assistants conversationnels), qui sont l'aboutissement (très) avancé de ces idées.
|
||
|
||
Le vocabulaire de base :
|
||
|
||
| Terme | Signification | Dans l'exemple ci-dessous |
|
||
|---|---|---|
|
||
| **Caractéristiques** (*features*, souvent `X`) | les informations d'entrée | 4 mesures d'une fleur |
|
||
| **Étiquette** / cible (*label*, `y`) | ce qu'on veut prédire | l'espèce de la fleur |
|
||
| **Classification** | prédire une catégorie | setosa, versicolor ou virginica |
|
||
| **Régression** | prédire un nombre | (ex. le prix d'une maison) |
|
||
| **Entraînement** (*fit*) | le modèle apprend sur des exemples | `modele.fit(X, y)` |
|
||
| **Prédiction** (*predict*) | le modèle devine pour de nouvelles données | `modele.predict(X_nouveau)` |
|
||
| **Jeu de test** | des exemples **cachés** pendant l'entraînement, pour évaluer honnêtement | 25 % des fleurs |
|
||
|
||
> ⚠️ **La règle d'or** : on évalue toujours un modèle sur des données qu'il n'a **jamais vues**. Sinon, c'est comme donner à un élève l'interrogation avec les réponses la veille : il aura 20/20 sans avoir rien compris.
|
||
|
||
#### 4.2 Bibliothèques clés
|
||
|
||
| Bibliothèque | Rôle |
|
||
|---|---|
|
||
| **scikit-learn** | le machine learning « classique » : classification, régression, regroupement, évaluation. Idéal pour débuter. |
|
||
| **NumPy / pandas** | préparer les données (voir section 3) |
|
||
| **PyTorch** | réseaux de neurones et *deep learning* (recherche et industrie) |
|
||
| **TensorFlow / Keras** | autre grande famille de deep learning ; Keras est très accessible |
|
||
| **Hugging Face Transformers** | utiliser des modèles pré-entraînés (texte, images, audio) |
|
||
| **SDK des fournisseurs d'IA** | appeler de grands modèles de langage via leurs API depuis Python |
|
||
|
||
```bash
|
||
pip install scikit-learn
|
||
```
|
||
|
||
#### 4.3 Exemple complet : reconnaître des espèces d'iris
|
||
|
||
Le jeu de données **iris** est le « Hello, World! » du machine learning : 150 fleurs, pour chacune 4 mesures (longueur et largeur des sépales et des pétales, en cm) et son espèce (*setosa*, *versicolor* ou *virginica*). Il est inclus dans scikit-learn : rien à télécharger.
|
||
|
||
Enregistre `iris_classification.py` :
|
||
|
||
```python
|
||
"""Classer des fleurs d'iris avec scikit-learn."""
|
||
|
||
from sklearn.datasets import load_iris
|
||
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
|
||
from sklearn.model_selection import train_test_split
|
||
from sklearn.neighbors import KNeighborsClassifier
|
||
from sklearn.tree import DecisionTreeClassifier
|
||
|
||
# 1. Charger les données : 150 fleurs, 4 mesures chacune, 3 espèces
|
||
iris = load_iris()
|
||
X = iris.data # les mesures (entrées)
|
||
y = iris.target # l'espèce, codée 0, 1 ou 2 (sortie à prédire)
|
||
print("Mesures :", iris.feature_names)
|
||
print("Espèces :", iris.target_names.tolist())
|
||
print("Première fleur :", X[0], "→", iris.target_names[y[0]])
|
||
|
||
# 2. Séparer : 75 % pour apprendre, 25 % pour évaluer
|
||
X_entrainement, X_test, y_entrainement, y_test = train_test_split(
|
||
X, y, test_size=0.25, random_state=42, stratify=y
|
||
)
|
||
print(f"\n{len(X_entrainement)} fleurs pour l'entraînement, {len(X_test)} pour le test")
|
||
|
||
# 3. Choisir un modèle et l'entraîner
|
||
modele = KNeighborsClassifier(n_neighbors=5)
|
||
modele.fit(X_entrainement, y_entrainement)
|
||
|
||
# 4. Prédire sur des fleurs jamais vues et mesurer la précision
|
||
predictions = modele.predict(X_test)
|
||
precision = accuracy_score(y_test, predictions)
|
||
print(f"\nPrécision des k plus proches voisins : {precision:.1%}")
|
||
print("Matrice de confusion :")
|
||
print(confusion_matrix(y_test, predictions))
|
||
print(classification_report(y_test, predictions, target_names=iris.target_names))
|
||
|
||
# 5. Comparer avec un autre modèle : même interface fit / predict !
|
||
arbre = DecisionTreeClassifier(max_depth=3, random_state=42)
|
||
arbre.fit(X_entrainement, y_entrainement)
|
||
print(f"Précision de l'arbre de décision : {arbre.score(X_test, y_test):.1%}")
|
||
|
||
# 6. Utiliser le modèle sur une nouvelle fleur cueillie dans le jardin
|
||
nouvelle_fleur = [[6.1, 2.9, 4.7, 1.4]] # longueur/largeur sépale, pétale (cm)
|
||
espece = iris.target_names[modele.predict(nouvelle_fleur)[0]]
|
||
print(f"\nLa nouvelle fleur est probablement : {espece}")
|
||
```
|
||
|
||
```bash
|
||
python iris_classification.py
|
||
```
|
||
|
||
```text
|
||
Mesures : ['sepal length (cm)', 'sepal width (cm)', 'petal length (cm)', 'petal width (cm)']
|
||
Espèces : ['setosa', 'versicolor', 'virginica']
|
||
Première fleur : [5.1 3.5 1.4 0.2] → setosa
|
||
|
||
112 fleurs pour l'entraînement, 38 pour le test
|
||
|
||
Précision des k plus proches voisins : 97.4%
|
||
Matrice de confusion :
|
||
[[12 0 0]
|
||
[ 0 13 0]
|
||
[ 0 1 12]]
|
||
precision recall f1-score support
|
||
|
||
setosa 1.00 1.00 1.00 12
|
||
versicolor 0.93 1.00 0.96 13
|
||
virginica 1.00 0.92 0.96 13
|
||
|
||
accuracy 0.97 38
|
||
macro avg 0.98 0.97 0.97 38
|
||
weighted avg 0.98 0.97 0.97 38
|
||
|
||
Précision de l'arbre de décision : 89.5%
|
||
|
||
La nouvelle fleur est probablement : versicolor
|
||
```
|
||
|
||
*(Les chiffres exacts peuvent légèrement varier selon ta version de scikit-learn.)*
|
||
|
||
**Décryptage :**
|
||
|
||
- **`train_test_split`** mélange et coupe les données. `random_state=42` fixe le hasard pour obtenir le même découpage à chaque exécution (reproductibilité). `stratify=y` garde la même proportion de chaque espèce dans les deux parties.
|
||
- **k plus proches voisins** (*k-NN*) : pour classer une nouvelle fleur, le modèle cherche les 5 fleurs d'entraînement les plus **ressemblantes** (les plus proches en mesures) et vote pour l'espèce majoritaire. Simple et intuitif !
|
||
- **Arbre de décision** : le modèle apprend une suite de questions (« longueur du pétale < 2,45 cm ? »), comme un jeu des 20 questions.
|
||
- **Tous les modèles de scikit-learn ont la même interface** : `fit` pour apprendre, `predict` pour prédire, `score` pour évaluer. Changer de modèle = changer une ligne.
|
||
- **Matrice de confusion** : ligne = vraie espèce, colonne = espèce prédite. La diagonale = bonnes réponses. Ici, une seule erreur : une *virginica* prise pour une *versicolor* (ces deux espèces se ressemblent).
|
||
- **Précision 97,4 %** : 37 fleurs bien classées sur 38. Sur un jeu de test de 38 exemples, une erreur de plus ou de moins change beaucoup le pourcentage : ne tire pas de conclusions hâtives sur de petits jeux de données (voir la *validation croisée* dans l'exercice 6).
|
||
|
||
> 🧭 **Et ChatGPT, Claude, etc. ?** Les grands modèles de langage sont des réseaux de neurones gigantesques entraînés sur d'énormes quantités de texte. Tu ne vas pas en entraîner un, mais tu peux les **utiliser** depuis Python via l'API de leur fournisseur (une requête HTTP, comme en section 2), ou faire tourner de petits modèles ouverts avec Hugging Face. Comprendre les bases vues ici (données, entraînement, évaluation, surapprentissage) t'aidera à les utiliser avec discernement.
|
||
|
||
#### 4.4 Aller plus loin en IA
|
||
|
||
1. **Consolider la data** (section 3) : 80 % du travail en machine learning consiste à préparer les données.
|
||
2. **scikit-learn** : les tutoriels officiels ; la régression linéaire, la normalisation des données (`StandardScaler`), la validation croisée, les *pipelines* ; le surapprentissage et comment l'éviter.
|
||
3. **Un peu de mathématiques** : statistiques, probabilités, algèbre linéaire de base (vecteurs, matrices). Pas besoin d'être un génie, mais l'intuition aide énormément.
|
||
4. **Compétitions pour débutants** : Kaggle propose des problèmes guidés (le *Titanic* est un classique) et des mini-cours gratuits.
|
||
5. **Deep learning** : le cours gratuit *Practical Deep Learning for Coders* (fast.ai), ou les tutoriels de PyTorch ; reconnaissance d'images, traitement du texte.
|
||
6. **Les grands modèles de langage** : appeler une API d'IA depuis Python, apprendre à rédiger de bonnes consignes, découvrir les « agents » et la recherche documentaire augmentée (*RAG*).
|
||
7. **Éthique** : biais dans les données, vie privée, impact environnemental. Un bon praticien de l'IA se pose ces questions.
|
||
|
||
### 5. Jeux vidéo avec pygame
|
||
|
||
#### 5.1 À quoi ça sert ?
|
||
|
||
Programmer un jeu est **la façon la plus amusante** de consolider tout ce que tu as appris : boucles, conditions, listes, classes, fichiers… et un peu de géométrie. Le résultat est immédiatement visible et partageable. Python n'est pas le langage des jeux AAA (trop lent pour cela), mais il est parfait pour des jeux 2D, des prototypes, des *game jams* et pour apprendre les concepts du jeu vidéo.
|
||
|
||
#### 5.2 Bibliothèques clés
|
||
|
||
| Bibliothèque | Points forts |
|
||
|---|---|
|
||
| **pygame** (ou sa variante communautaire **pygame-ce**) | la référence historique : fenêtre, dessin, images, sons, clavier/souris. Énormément de tutoriels. |
|
||
| **Arcade** | plus moderne, orientée objet, bien documentée |
|
||
| **Pyxel** | moteur rétro « pixel art » avec éditeur intégré, très ludique |
|
||
| **Ursina** | jeux en 3D simples |
|
||
| **Godot** (langage GDScript, proche de Python) | un vrai moteur de jeu complet, si tu veux aller plus loin |
|
||
|
||
```bash
|
||
pip install pygame
|
||
```
|
||
|
||
#### 5.3 Le concept clé : la boucle de jeu
|
||
|
||
Un jeu vidéo est un **dessin animé interactif**. Comme un dessin animé, il affiche des **images** successives très rapidement (60 par seconde). Entre chaque image, le programme fait toujours les trois mêmes choses :
|
||
|
||
```text
|
||
┌──────────────────────────────────────────────────┐
|
||
│ tant que le jeu tourne : │
|
||
│ 1. ÉVÉNEMENTS → touches, souris, fermeture │
|
||
│ 2. MISE À JOUR → déplacer, collisions, score │
|
||
│ 3. AFFICHAGE → effacer, tout redessiner │
|
||
│ (attendre pour tenir 60 images par seconde) │
|
||
└──────────────────────────────────────────────────┘
|
||
```
|
||
|
||
C'est une simple boucle `while` ([chapitre 03](03-boucles.md)) ! Tout jeu, du Pong à un jeu moderne, repose sur ce squelette.
|
||
|
||
Autre notion : le **repère de l'écran**. L'origine `(0, 0)` est **en haut à gauche** ; `x` augmente vers la droite et `y` **vers le bas** (l'inverse des maths du collège !).
|
||
|
||
#### 5.4 Exemple complet : Attrape-étoiles
|
||
|
||
Des étoiles tombent du ciel ; déplace le panier avec les flèches pour les attraper. Chaque étoile ratée coûte une vie.
|
||
|
||
Enregistre `attrape_etoiles.py` :
|
||
|
||
```python
|
||
"""Attrape-étoiles : un mini-jeu avec pygame.
|
||
|
||
Déplace le panier avec les flèches gauche/droite pour attraper les étoiles.
|
||
Tu as 3 vies : chaque étoile ratée en coûte une.
|
||
"""
|
||
|
||
import random
|
||
|
||
import pygame
|
||
|
||
LARGEUR, HAUTEUR = 640, 480
|
||
IPS = 60 # images par seconde
|
||
BLANC = (255, 255, 255)
|
||
NUIT = (15, 20, 45)
|
||
JAUNE = (255, 215, 0)
|
||
ORANGE = (240, 130, 40)
|
||
|
||
|
||
class Panier:
|
||
def __init__(self) -> None:
|
||
self.rect = pygame.Rect(0, 0, 100, 20)
|
||
self.rect.midbottom = (LARGEUR // 2, HAUTEUR - 20)
|
||
self.vitesse = 7
|
||
|
||
def deplacer(self, touches) -> None:
|
||
if touches[pygame.K_LEFT]:
|
||
self.rect.x -= self.vitesse
|
||
if touches[pygame.K_RIGHT]:
|
||
self.rect.x += self.vitesse
|
||
self.rect.clamp_ip(pygame.Rect(0, 0, LARGEUR, HAUTEUR)) # reste à l'écran
|
||
|
||
def dessiner(self, ecran) -> None:
|
||
pygame.draw.rect(ecran, ORANGE, self.rect, border_radius=6)
|
||
|
||
|
||
class Etoile:
|
||
def __init__(self) -> None:
|
||
self.rect = pygame.Rect(random.randint(0, LARGEUR - 20), -20, 20, 20)
|
||
self.vitesse = random.uniform(2, 5)
|
||
self.y = float(self.rect.y)
|
||
|
||
def tomber(self) -> None:
|
||
self.y += self.vitesse
|
||
self.rect.y = int(self.y)
|
||
|
||
def dessiner(self, ecran) -> None:
|
||
pygame.draw.circle(ecran, JAUNE, self.rect.center, 10)
|
||
|
||
|
||
def main() -> None:
|
||
pygame.init()
|
||
ecran = pygame.display.set_mode((LARGEUR, HAUTEUR))
|
||
pygame.display.set_caption("Attrape-étoiles")
|
||
horloge = pygame.time.Clock()
|
||
police = pygame.font.Font(None, 36)
|
||
|
||
panier = Panier()
|
||
etoiles: list[Etoile] = []
|
||
score = 0
|
||
vies = 3
|
||
en_cours = True
|
||
|
||
while en_cours:
|
||
# 1. ÉVÉNEMENTS : clavier, souris, fermeture de la fenêtre
|
||
for evenement in pygame.event.get():
|
||
if evenement.type == pygame.QUIT:
|
||
en_cours = False
|
||
|
||
# 2. MISE À JOUR : on fait évoluer le monde du jeu
|
||
if vies > 0:
|
||
panier.deplacer(pygame.key.get_pressed())
|
||
if random.random() < 0.03: # environ 2 nouvelles étoiles par seconde
|
||
etoiles.append(Etoile())
|
||
for etoile in etoiles[:]: # on parcourt une copie pour pouvoir retirer
|
||
etoile.tomber()
|
||
if etoile.rect.colliderect(panier.rect):
|
||
etoiles.remove(etoile)
|
||
score += 1
|
||
elif etoile.rect.top > HAUTEUR:
|
||
etoiles.remove(etoile)
|
||
vies -= 1
|
||
|
||
# 3. AFFICHAGE : on redessine tout, puis on montre l'image
|
||
ecran.fill(NUIT)
|
||
for etoile in etoiles:
|
||
etoile.dessiner(ecran)
|
||
panier.dessiner(ecran)
|
||
texte = police.render(f"Score : {score} Vies : {vies}", True, BLANC)
|
||
ecran.blit(texte, (10, 10))
|
||
if vies <= 0:
|
||
fin = police.render("Perdu ! Ferme la fenêtre pour quitter.", True, BLANC)
|
||
ecran.blit(fin, fin.get_rect(center=(LARGEUR // 2, HAUTEUR // 2)))
|
||
pygame.display.flip()
|
||
|
||
horloge.tick(IPS) # limite à 60 images par seconde
|
||
|
||
pygame.quit()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|
||
```
|
||
|
||
```bash
|
||
python attrape_etoiles.py
|
||
```
|
||
|
||
**Décryptage :**
|
||
|
||
- **`pygame.Rect`** est un rectangle (position + taille) avec des outils géniaux : `midbottom`, `center`, `colliderect` (détection de collision entre deux rectangles), `clamp_ip` (garder dans l'écran).
|
||
- **`pygame.event.get()`** vide la file des événements. **Indispensable** : sans cette ligne, le système croit que le programme ne répond plus.
|
||
- **`pygame.key.get_pressed()`** donne l'état de toutes les touches **en ce moment** : idéal pour un mouvement continu tant qu'on garde la touche enfoncée. (Pour une action unique, comme sauter ou tirer, on écoute plutôt l'événement `pygame.KEYDOWN`.)
|
||
- **`ecran.fill(NUIT)`** efface tout à chaque image : on ne déplace pas les dessins, on **redessine** tout à la nouvelle position. C'est le principe du dessin animé.
|
||
- **`pygame.display.flip()`** affiche l'image qu'on vient de dessiner (on dessine en coulisses, puis on montre d'un coup, pour éviter le scintillement).
|
||
- **`horloge.tick(60)`** fait une petite pause pour ne pas dépasser 60 images par seconde : le jeu tourne à la même vitesse sur tous les ordinateurs.
|
||
- **`for etoile in etoiles[:]`** : on parcourt une **copie** de la liste, car on retire des éléments pendant le parcours (le modifier directement ferait sauter des éléments).
|
||
- On garde `self.y` en `float` car un `Rect` ne stocke que des entiers : sans cela, une vitesse de 2,7 serait arrondie à chaque image.
|
||
- Les **classes** ([chapitre 10](10-poo-classes-objets.md)) organisent naturellement un jeu : chaque objet du jeu sait se mettre à jour et se dessiner.
|
||
|
||
#### 5.5 Aller plus loin en jeux
|
||
|
||
1. **Classiques à recréer** (dans cet ordre de difficulté) : Pong, Snake, Casse-briques, Space Invaders, Flappy Bird, un jeu de plateforme.
|
||
2. **Images et sons** : charge des images (`pygame.image.load`), joue des sons (`pygame.mixer`). Des ressources libres de droits existent, par exemple sur Kenney.nl ou OpenGameArt.org.
|
||
3. **Organisation** : les classes `pygame.sprite.Sprite` et `pygame.sprite.Group`, les états du jeu (menu, partie, pause, fin), le temps écoulé (`dt`) pour des mouvements indépendants de la vitesse de la machine.
|
||
4. **Physique simple** : gravité, saut, rebonds (un peu de vecteurs avec `pygame.math.Vector2`).
|
||
5. **Partage** : participe à une *game jam* (sur itch.io), publie ton jeu sur GitHub, ou empaquette-le avec `pyinstaller`, voire pour le navigateur avec `pygbag`.
|
||
6. **Changer d'outil** : quand tu voudras des jeux plus ambitieux, essaie Arcade, ou passe à Godot (dont le langage GDScript te paraîtra très familier).
|
||
|
||
### 6. Tableau comparatif : quelle spécialisation choisir ?
|
||
|
||
| Critère | 🤖 Automatisation | 🌐 Web | 📊 Data | 🧠 IA / ML | 🎮 Jeux |
|
||
|---|---|---|---|---|---|
|
||
| **Tu aimes…** | gagner du temps, l'efficacité | créer des sites, des services utilisés par d'autres | comprendre, chercher des tendances, convaincre avec des chiffres | les défis scientifiques, les prédictions | créer, le visuel, l'interactivité |
|
||
| **Bibliothèques phares** | pathlib, shutil, requests, openpyxl | Flask, FastAPI, Django | pandas, matplotlib, Jupyter | scikit-learn, PyTorch | pygame, Arcade |
|
||
| **Difficulté pour débuter** | ⭐ très accessible | ⭐⭐ (il faut aussi HTML/CSS) | ⭐⭐ | ⭐⭐⭐ (maths, statistiques) | ⭐⭐ |
|
||
| **Premier résultat utile** | en une soirée | en un week-end | en une soirée | en une soirée (mais comprendre prend du temps) | en un week-end |
|
||
| **Autres connaissances utiles** | système de fichiers, ligne de commande | HTML, CSS, HTTP, SQL, un peu de JavaScript | statistiques, SQL, Excel | maths (stats, algèbre linéaire), data | un peu de géométrie, game design, graphisme |
|
||
| **Débouchés** | utile dans **tous** les métiers ; DevOps, administration système | développeur back-end, full-stack | data analyst, data engineer | data scientist, ingénieur ML | développement de jeux (souvent avec d'autres langages ensuite), prototypage |
|
||
| **Projet type pour débuter** | ranger ses Téléchargements | une API de tâches | analyser ses dépenses | prédire le prix d'un logement | Snake |
|
||
|
||
> 🧭 **Comment choisir ?** Pars de **ce que tu as envie de construire**, pas de ce qui est « à la mode ». La motivation est ton meilleur carburant. Et rien ne t'empêche de combiner : un script qui **automatise** la collecte de données, les **analyse** avec pandas et publie un tableau de bord sur le **web**, c'est exactement ce que fait le mini-projet de ce chapitre. Si tu hésites vraiment, commence par l'automatisation : c'est utile tout de suite et ça renforce tes bases pour tout le reste.
|
||
|
||
---
|
||
|
||
## Pièges fréquents
|
||
|
||
### 1. Installer les bibliothèques hors du venv (ou dans le mauvais)
|
||
|
||
**Symptôme** : `ModuleNotFoundError: No module named 'pandas'` alors que tu viens de l'installer ; ou VS Code souligne `import flask` en jaune.
|
||
|
||
**Correction** : active le bon venv avant `pip install` (le `(.venv)` doit être visible), et sélectionne le même interpréteur dans VS Code (*Python: Select Interpreter*). Rappel : `pip install scikit-learn` mais `import sklearn` ; `pip install pillow` mais `import PIL` ; `pip install beautifulsoup4` mais `import bs4`. Le nom du paquet et le nom du module ne sont pas toujours identiques.
|
||
|
||
### 2. Nommer son fichier comme la bibliothèque
|
||
|
||
**Symptôme** : `ImportError: cannot import name 'Flask' from partially initialized module 'flask'` ou `AttributeError: module 'pandas' has no attribute 'read_csv'`.
|
||
|
||
**Correction** : ton fichier s'appelle `flask.py`, `pandas.py`, `pygame.py`… et masque la vraie bibliothèque. Renomme-le (`mon_app.py`, `analyse.py`, `jeu.py`) et supprime le dossier `__pycache__`.
|
||
|
||
### 3. Lancer un script d'automatisation directement sur ses vrais fichiers
|
||
|
||
**Symptôme** : 500 photos renommées n'importe comment, des fichiers écrasés ou perdus.
|
||
|
||
**Correction** : prévois toujours un **mode simulation** qui affiche les actions sans les faire ; teste sur une **copie** ou un dossier factice ; vérifie qu'aucun fichier n'est écrasé (`exists()`) ; préfère déplacer plutôt que supprimer. Avec Git, tu peux aussi versionner les fichiers texte avant une transformation.
|
||
|
||
### 4. Le serveur web « ne marche pas »
|
||
|
||
**Symptôme** : le navigateur affiche « Impossible de se connecter », ou les modifications du code ne s'affichent pas, ou `Address already in use`.
|
||
|
||
**Correction** : le serveur doit **tourner** dans un terminal pendant que tu utilises le navigateur (ne ferme pas ce terminal). Vérifie l'adresse et le port (`5000` pour Flask, `8000` pour FastAPI). Si le port est occupé, un autre serveur tourne déjà : arrête-le avec `Ctrl+C`, ou change de port (`app.run(port=5001)`, `fastapi dev api.py --port 8001`). Active `debug=True` (Flask) ou `--reload` (uvicorn) pour le rechargement automatique.
|
||
|
||
### 5. Le graphique matplotlib ne s'affiche pas (ou bloque le programme)
|
||
|
||
**Symptôme** : rien ne s'affiche ; ou le programme semble figé après le graphique.
|
||
|
||
**Correction** : il faut appeler `plt.show()` à la fin. Ce dernier **bloque** le script jusqu'à la fermeture de la fenêtre : c'est normal. Sur un serveur (ou si aucune fenêtre ne peut s'ouvrir), utilise `figure.savefig("graphique.png")` et ouvre l'image.
|
||
|
||
### 6. Évaluer un modèle sur ses données d'entraînement
|
||
|
||
**Symptôme** : ton modèle affiche 100 % de précision… puis se trompe tout le temps sur de nouvelles données.
|
||
|
||
**Correction** : c'est du **surapprentissage** (le modèle a appris par cœur). Sépare toujours tes données avec `train_test_split` **avant** l'entraînement, et n'évalue que sur le jeu de test. Pour des résultats plus fiables, utilise la validation croisée (`cross_val_score`).
|
||
|
||
### 7. Oublier de traiter les événements ou de rafraîchir l'écran dans pygame
|
||
|
||
**Symptôme** : la fenêtre est noire, ou « ne répond pas », ou ne se ferme pas.
|
||
|
||
**Correction** : dans chaque tour de boucle, appelle `pygame.event.get()` (et gère `pygame.QUIT`), dessine, puis `pygame.display.flip()`. Sans `horloge.tick(60)`, le jeu tourne trop vite et utilise 100 % du processeur.
|
||
|
||
### 8. Vouloir tout apprendre en même temps
|
||
|
||
**Symptôme** : tu enchaînes les tutoriels Django, PyTorch et pygame en une semaine… et tu as l'impression de ne rien maîtriser.
|
||
|
||
**Correction** : choisis **un** domaine pour les prochains mois et réalise **un projet concret** de bout en bout ([chapitre 15](15-projet-final.md)). La profondeur vaut mieux que la largeur au début. Les autres domaines seront beaucoup plus faciles ensuite : les bases de Python sont les mêmes partout.
|
||
|
||
---
|
||
|
||
## Exercices
|
||
|
||
> Chaque exercice correspond à un domaine. Fais au moins ceux de la spécialisation qui t'attire, et un ou deux autres par curiosité.
|
||
|
||
### Exercice 1 ⭐ — Automatisation : inventaire d'un dossier
|
||
|
||
Écris un script `inventaire.py` qui parcourt un dossier **et tous ses sous-dossiers** (passé en argument, ou le dossier courant par défaut) et affiche, pour chaque extension, le **nombre de fichiers** et la **taille totale**, triés du plus fréquent au moins fréquent, avec un total à la fin.
|
||
|
||
Écris une fonction `taille_lisible(octets)` qui renvoie `"500 octets"`, `"2.0 Ko"`, `"5.0 Mo"`, `"3.0 Go"`…
|
||
|
||
*Indices* : `Path.rglob("*")` parcourt récursivement ; `fichier.stat().st_size` donne la taille en octets ; `collections.Counter` ([chapitre 09](09-modules-bibliotheque-standard.md)) est parfait pour compter.
|
||
|
||
### Exercice 2 ⭐ — Web : nouvelles routes Flask
|
||
|
||
Pars de l'application de citations Flask (section 2.3) et ajoute :
|
||
|
||
1. une route `/citation/<int:numero>` qui affiche la citation n° `numero` (en commençant à 1), et renvoie une erreur **404** si le numéro n'existe pas ;
|
||
2. une route `/auteurs` qui affiche la liste HTML des auteurs, **sans doublon** et triés par ordre alphabétique, avec leur nombre dans le titre ;
|
||
3. une route `/api/auteurs` qui renvoie cette même liste en JSON.
|
||
|
||
Écris quelques tests avec `app.test_client()`.
|
||
|
||
*Indices* : `<int:numero>` dans la route passe un entier à la fonction ; `abort(404)` (à importer de `flask`) renvoie une erreur ; dans un template Jinja2, `{% for x in liste %}...{% endfor %}` fait une boucle et `{{ liste|length }}` donne la longueur.
|
||
|
||
### Exercice 3 ⭐⭐ — Web : enrichir l'API FastAPI
|
||
|
||
Améliore l'API des tâches (section 2.4) :
|
||
|
||
1. la validation : le titre doit faire entre 1 et 100 caractères et la priorité être comprise entre 1 et 3 (sinon, erreur 422 automatique) ;
|
||
2. `GET /taches` accepte un paramètre facultatif `recherche` (recherche insensible à la casse dans le titre) et renvoie les tâches triées par **priorité décroissante**, puis par `id` ;
|
||
3. une route `GET /stats` qui renvoie `{"total": ..., "terminees": ..., "restantes": ..., "pourcentage": ...}`.
|
||
|
||
Écris des tests avec `TestClient`. Problème : les tâches sont stockées dans une variable globale partagée entre les tests. Écris une fixture qui la **réinitialise** avant chaque test.
|
||
|
||
*Indices* : `from pydantic import Field` puis `titre: str = Field(min_length=1, max_length=100)` et `priorite: int = Field(default=1, ge=1, le=3)` (`ge` = *greater or equal*, `le` = *less or equal*) ; `@pytest.fixture(autouse=True)` applique une fixture à tous les tests du fichier.
|
||
|
||
### Exercice 4 ⭐⭐ — Data : enquête à la boulangerie
|
||
|
||
Avec `ventes.csv` (section 3.3) et pandas, réponds par un script aux questions suivantes :
|
||
|
||
1. Quel produit s'est le plus vendu **en quantité** ? (et combien d'unités ?)
|
||
2. Quel est le chiffre d'affaires de chaque ville ?
|
||
3. Quelle est la vente (ligne) qui a rapporté le plus ? Affiche le produit, la date au format `jj/mm/aaaa`, la ville et le montant.
|
||
4. Affiche les ventes de viennoiseries à Lyon de plus de 100 unités (colonnes date, produit, quantité).
|
||
5. Quel est le **prix moyen** d'un article dans chaque catégorie (chiffre d'affaires total ÷ quantité totale) ?
|
||
6. Exporte un résumé par produit (quantité totale, chiffre d'affaires, nombre de ventes) dans `resume_produits.csv`, trié par chiffre d'affaires décroissant.
|
||
|
||
*Indices* : `Series.idxmax()` renvoie l'étiquette (ou l'index) de la valeur maximale ; `df.loc[index]` renvoie une ligne ; `groupby(...).agg(nom=("colonne", "sum"), ...)` crée plusieurs colonnes résumées d'un coup ; `df.to_csv("fichier.csv")`.
|
||
|
||
### Exercice 5 ⭐⭐ — Data : un graphique soigné
|
||
|
||
Réalise un **diagramme en barres horizontales** du chiffre d'affaires par produit :
|
||
|
||
- barres triées (le plus gros chiffre d'affaires en haut) ;
|
||
- montant affiché au bout de chaque barre (`"1524 €"`) ;
|
||
- un titre, un nom d'axe, sans les bordures du haut et de droite ;
|
||
- enregistré dans `ca_par_produit.png`.
|
||
|
||
*Indices* : `axe.barh(...)` renvoie les barres ; `axe.bar_label(barres, labels=[...], padding=4)` ajoute les étiquettes ; `axe.spines[["top", "right"]].set_visible(False)` masque des bordures.
|
||
|
||
### Exercice 6 ⭐⭐⭐ — IA : classer des vins et choisir ses réglages
|
||
|
||
scikit-learn contient un autre jeu de données : `load_wine()` (178 vins, 13 mesures chimiques, 3 cépages).
|
||
|
||
1. Sépare les données (25 % de test, `random_state=42`, stratifié) et entraîne un k-NN (`k=5`). Note la précision.
|
||
2. Recommence en ajoutant une **mise à l'échelle** des données avec `StandardScaler`, dans un *pipeline*. Compare. Pourquoi une telle différence ?
|
||
3. Pour `k` allant de 1 à 15 (impairs), calcule la précision moyenne par **validation croisée à 5 plis** sur les données d'entraînement uniquement. Choisis le meilleur `k`, entraîne le modèle final et évalue-le **une seule fois** sur le jeu de test.
|
||
|
||
*Indices* : `from sklearn.pipeline import make_pipeline`, `make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=k))` ; `from sklearn.model_selection import cross_val_score`, `cross_val_score(modele, X, y, cv=5)` renvoie 5 scores.
|
||
|
||
### Exercice 7 ⭐⭐⭐ — Automatisation : renommage en lot sécurisé
|
||
|
||
Écris un outil en ligne de commande `renommer_lot.py` :
|
||
|
||
```bash
|
||
python renommer_lot.py photos vacances --extension .jpg # simulation
|
||
python renommer_lot.py photos vacances --extension .jpg --executer
|
||
```
|
||
|
||
qui renomme les fichiers (triés par nom) en `vacances_001.jpg`, `vacances_002.jpg`… avec les exigences suivantes :
|
||
|
||
- seuls les fichiers de l'extension demandée sont traités (tous si l'option est absente), en ignorant la casse ; l'extension est mise en minuscules ;
|
||
- les fichiers cachés (commençant par `.`) sont ignorés ;
|
||
- mode simulation par défaut ;
|
||
- **aucun fichier ne doit jamais être écrasé**, y compris dans le cas piégeux où un fichier porte déjà un des noms cibles (ex. `vacances_002.jpg` existe et fait partie des fichiers à renommer) ;
|
||
- relancer le script une deuxième fois ne doit rien casser.
|
||
|
||
### Exercice 8 ⭐⭐⭐ — Jeux : Attrape-étoiles, le retour
|
||
|
||
Améliore le jeu de la section 5.4 :
|
||
|
||
1. un **niveau** qui augmente toutes les 10 étoiles attrapées, rendant les étoiles plus rapides et plus fréquentes ;
|
||
2. des **étoiles rouges piégées** (1 sur 5) : les attraper fait perdre une vie, les laisser tomber ne coûte rien ;
|
||
3. en fin de partie : `R` pour rejouer, `Échap` pour quitter ;
|
||
4. un **meilleur score** sauvegardé dans un fichier `record.txt` et affiché en permanence.
|
||
|
||
Organise le code avec une classe `Jeu` qui contient l'état de la partie (score, vies, panier, étoiles) et des méthodes `recommencer`, `mettre_a_jour` et `dessiner`. Utilise des `@property` ([chapitre 11](11-poo-avancee-heritage-typage.md)) pour `niveau` et `termine`.
|
||
|
||
---
|
||
|
||
## Mini-projet
|
||
|
||
### 🌦️ Ma station météo : automatisation + data + web
|
||
|
||
Tu vas combiner **trois** spécialisations dans un seul petit projet : un script **automatise** la production d'un fichier de relevés météo, un module **analyse** ces données avec pandas et produit un graphique, et une application **web** Flask affiche le tout dans un tableau de bord.
|
||
|
||
#### Cahier des charges
|
||
|
||
1. **Génération des données** (`generer_donnees.py`) : produit un fichier `releves.csv` avec une ligne par jour et par ville (Brest, Lyon, Marseille) sur une année, et les colonnes `date, ville, temp_min, temp_max, pluie_mm`. Les valeurs sont fictives mais **réalistes** : plus froid en hiver qu'en été, Marseille plus chaude que Brest, environ un jour de pluie sur trois, et `temp_min <= temp_max`. Avec la même « graine », le fichier doit être **identique** à chaque exécution.
|
||
2. **Analyse** (`analyse.py`), trois fonctions :
|
||
- `charger(chemin)` → DataFrame avec une colonne `temp_moyenne` en plus ;
|
||
- `resume_par_ville(releves)` → par ville : température moyenne, record de chaleur, record de froid, nombre de jours de pluie, cumul de pluie ;
|
||
- `tracer_mensuel(releves, chemin_image)` → graphique de la température moyenne par mois, une courbe par ville, enregistré en PNG.
|
||
3. **Tableau de bord** (`app.py`) : une page `/` qui affiche le nombre de relevés, le tableau résumé et le graphique ; une route `/api/resume` qui renvoie le résumé en JSON.
|
||
4. **Tests** (`test_meteo.py`) : au moins 5 tests pytest (génération, cohérence des données, résumé, graphique, application web), qui n'utilisent que des fichiers temporaires.
|
||
5. **Qualité** : venv, `requirements.txt`, `.gitignore`, `ruff check` et `ruff format --check` sans erreur, projet sous Git ([chapitre 12](12-outils-venv-pip-git.md)).
|
||
|
||
#### Étapes suggérées
|
||
|
||
1. Prépare le projet : dossier, venv, `pip install pandas matplotlib flask pytest ruff`, `pip freeze > requirements.txt`, `git init`.
|
||
2. Écris le générateur. Commence simplement (températures aléatoires), puis ajoute l'effet des saisons. Ouvre le CSV pour vérifier.
|
||
3. Écris `analyse.py` et teste chaque fonction dans un petit script ou dans un notebook.
|
||
4. Écris l'application Flask. Commence par afficher juste le nombre de relevés, puis le tableau, puis l'image.
|
||
5. Écris les tests, lance ruff, committe.
|
||
|
||
*Indices* :
|
||
|
||
- Utilise `random.Random(graine)` pour un générateur de hasard **reproductible**, et `hasard.gauss(moyenne, ecart_type)` pour des variations naturelles.
|
||
- L'effet des saisons peut être imité par un cosinus : `-8 * math.cos(2 * math.pi * (jour_de_l_annee - 15) / 365)` vaut environ -8 mi-janvier et +8 mi-juillet. `date.timetuple().tm_yday` donne le numéro du jour dans l'année.
|
||
- `DataFrame.to_html()` produit un tableau HTML ; dans un template Jinja2, `{{ tableau|safe }}` l'insère sans l'échapper (uniquement pour du HTML que **tu** as généré).
|
||
- Flask sert automatiquement les fichiers du dossier `static/` à l'adresse `/static/...`.
|
||
- Sur un serveur web, utilise `matplotlib.use("Agg")` (dessin sans fenêtre) et `plt.close(figure)` après `savefig` pour libérer la mémoire.
|
||
|
||
#### Pistes d'amélioration
|
||
|
||
- Remplace les données fictives par de **vraies données** : l'API gratuite Open-Meteo (`requests`) fournit l'historique météo de n'importe quelle ville.
|
||
- Ajoute un formulaire pour choisir la ville et la période affichées.
|
||
- Ajoute un modèle scikit-learn de régression qui prédit la température du lendemain à partir des jours précédents (la quatrième spécialisation !).
|
||
- Planifie la mise à jour quotidienne des données.
|
||
- Transforme le tableau de bord en application Streamlit et compare les deux approches.
|
||
|
||
---
|
||
|
||
## Corrigés
|
||
|
||
<details>
|
||
<summary>Corrigé de l'exercice 1</summary>
|
||
|
||
```python
|
||
"""Inventaire d'un dossier : nombre de fichiers et taille totale par extension."""
|
||
|
||
import sys
|
||
from collections import Counter
|
||
from pathlib import Path
|
||
|
||
|
||
def taille_lisible(octets: float) -> str:
|
||
"""Convertit un nombre d'octets en texte lisible (Ko, Mo, Go)."""
|
||
if octets < 1024:
|
||
return f"{octets} octets"
|
||
for unite in ("Ko", "Mo", "Go"):
|
||
octets /= 1024
|
||
if octets < 1024:
|
||
return f"{octets:.1f} {unite}"
|
||
return f"{octets / 1024:.1f} To"
|
||
|
||
|
||
def inventorier(dossier: Path) -> tuple[Counter, Counter]:
|
||
nombres: Counter = Counter()
|
||
tailles: Counter = Counter()
|
||
for fichier in dossier.rglob("*"): # rglob : parcourt aussi les sous-dossiers
|
||
if fichier.is_file():
|
||
extension = fichier.suffix.lower() or "(sans extension)"
|
||
nombres[extension] += 1
|
||
tailles[extension] += fichier.stat().st_size
|
||
return nombres, tailles
|
||
|
||
|
||
def main() -> None:
|
||
dossier = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
|
||
nombres, tailles = inventorier(dossier)
|
||
print(f"Inventaire de {dossier.resolve()}\n")
|
||
print(f"{'Extension':<18}{'Fichiers':>9}{'Taille':>14}")
|
||
for extension, nombre in nombres.most_common():
|
||
print(f"{extension:<18}{nombre:>9}{taille_lisible(tailles[extension]):>14}")
|
||
print(f"\nTotal : {nombres.total()} fichiers, {taille_lisible(tailles.total())}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|
||
```
|
||
|
||
Exemple de sortie :
|
||
|
||
```text
|
||
Inventaire de /home/camille/bazar
|
||
|
||
Extension Fichiers Taille
|
||
.jpg 2 0 octets
|
||
(sans extension) 1 0 octets
|
||
.pdf 1 0 octets
|
||
...
|
||
.png 1 2.9 Ko
|
||
|
||
Total : 10 fichiers, 2.9 Ko
|
||
```
|
||
|
||
**Explications** :
|
||
|
||
- Deux `Counter` : un pour les nombres, un pour les tailles. `Counter` crée automatiquement une entrée à 0 pour une nouvelle clé, d'où `+=` sans vérification préalable.
|
||
- `most_common()` trie par fréquence décroissante ; `total()` fait la somme de toutes les valeurs.
|
||
- `fichier.suffix.lower() or "(sans extension)"` : si le suffixe est vide (chaîne vide = faux), on prend le texte de remplacement.
|
||
- `taille_lisible` divise par 1024 tant que le nombre est trop grand pour l'unité courante. Les mises en forme `:<18` (aligné à gauche sur 18 caractères) et `:>9` (aligné à droite) produisent un tableau propre ([chapitre 01](01-variables-types-operateurs.md)).
|
||
- Ce script ne modifie rien : on peut le lancer sans crainte sur n'importe quel dossier.
|
||
|
||
</details>
|
||
|
||
<details>
|
||
<summary>Corrigé de l'exercice 2</summary>
|
||
|
||
`app_flask_ex.py` :
|
||
|
||
```python
|
||
"""Exercice 2 : l'application de citations, avec deux nouvelles routes."""
|
||
|
||
import random
|
||
|
||
from flask import Flask, abort, jsonify, render_template_string
|
||
|
||
app = Flask(__name__)
|
||
|
||
CITATIONS = [
|
||
{"auteur": "Victor Hugo", "texte": "La musique, c'est du bruit qui pense."},
|
||
{
|
||
"auteur": "Marie Curie",
|
||
"texte": "Rien dans la vie n'est à craindre, tout est à comprendre.",
|
||
},
|
||
{
|
||
"auteur": "Albert Camus",
|
||
"texte": "Au milieu de l'hiver, j'ai découvert en moi un invincible été.",
|
||
},
|
||
]
|
||
|
||
PAGE_CITATION = """
|
||
<h1>Citation n° {{ numero }}</h1>
|
||
<blockquote>« {{ citation.texte }} »</blockquote>
|
||
<p>— {{ citation.auteur }}</p>
|
||
"""
|
||
|
||
PAGE_AUTEURS = """
|
||
<h1>Auteurs ({{ auteurs|length }})</h1>
|
||
<ul>
|
||
{% for auteur in auteurs %}
|
||
<li>{{ auteur }}</li>
|
||
{% endfor %}
|
||
</ul>
|
||
"""
|
||
|
||
|
||
@app.route("/")
|
||
def accueil():
|
||
citation = random.choice(CITATIONS)
|
||
return f"« {citation['texte']} » — {citation['auteur']}"
|
||
|
||
|
||
@app.route("/citation/<int:numero>")
|
||
def citation_numero(numero: int):
|
||
if not 1 <= numero <= len(CITATIONS):
|
||
abort(404)
|
||
return render_template_string(
|
||
PAGE_CITATION, numero=numero, citation=CITATIONS[numero - 1]
|
||
)
|
||
|
||
|
||
@app.route("/auteurs")
|
||
def auteurs():
|
||
noms = sorted({c["auteur"] for c in CITATIONS})
|
||
return render_template_string(PAGE_AUTEURS, auteurs=noms)
|
||
|
||
|
||
@app.route("/api/auteurs")
|
||
def api_auteurs():
|
||
return jsonify(sorted({c["auteur"] for c in CITATIONS}))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
app.run(debug=True)
|
||
```
|
||
|
||
`test_app_flask_ex.py` :
|
||
|
||
```python
|
||
from app_flask_ex import app
|
||
|
||
client = app.test_client()
|
||
|
||
|
||
def test_citation_existante():
|
||
reponse = client.get("/citation/2")
|
||
assert reponse.status_code == 200
|
||
assert "Marie Curie" in reponse.get_data(as_text=True)
|
||
|
||
|
||
def test_citation_inexistante():
|
||
assert client.get("/citation/99").status_code == 404
|
||
assert client.get("/citation/0").status_code == 404
|
||
|
||
|
||
def test_auteurs():
|
||
html = client.get("/auteurs").get_data(as_text=True)
|
||
assert "Auteurs (3)" in html
|
||
assert html.index("Albert Camus") < html.index("Victor Hugo") # ordre alphabétique
|
||
|
||
|
||
def test_api_auteurs():
|
||
assert client.get("/api/auteurs").get_json() == [
|
||
"Albert Camus",
|
||
"Marie Curie",
|
||
"Victor Hugo",
|
||
]
|
||
```
|
||
|
||
Résultat : `4 passed`.
|
||
|
||
**Explications** :
|
||
|
||
- `<int:numero>` : Flask n'accepte que des entiers dans cette partie de l'URL et convertit automatiquement. `/citation/abc` donne directement une 404.
|
||
- Les humains comptent à partir de 1, les listes Python à partir de 0 : d'où `CITATIONS[numero - 1]` et la vérification `1 <= numero <= len(CITATIONS)`. Sans elle, `/citation/0` renverrait… `CITATIONS[-1]`, la dernière citation !
|
||
- `{c["auteur"] for c in CITATIONS}` est une compréhension d'**ensemble** : les doublons disparaissent. `sorted()` renvoie une liste triée.
|
||
- `{% for %}` et `|length` sont la syntaxe Jinja2 pour les boucles et les filtres.
|
||
|
||
</details>
|
||
|
||
<details>
|
||
<summary>Corrigé de l'exercice 3</summary>
|
||
|
||
`api_taches_ex.py` :
|
||
|
||
```python
|
||
"""Exercice 3 : l'API des tâches, avec recherche, tri et statistiques."""
|
||
|
||
from fastapi import FastAPI, HTTPException
|
||
from pydantic import BaseModel, Field
|
||
|
||
app = FastAPI(title="API des tâches")
|
||
|
||
|
||
class NouvelleTache(BaseModel):
|
||
titre: str = Field(min_length=1, max_length=100)
|
||
priorite: int = Field(default=1, ge=1, le=3)
|
||
|
||
|
||
class Tache(NouvelleTache):
|
||
id: int
|
||
terminee: bool = False
|
||
|
||
|
||
taches: dict[int, Tache] = {}
|
||
prochain_id = 1
|
||
|
||
|
||
@app.get("/taches")
|
||
def lister_taches(
|
||
terminee: bool | None = None, recherche: str | None = None
|
||
) -> list[Tache]:
|
||
resultat = list(taches.values())
|
||
if terminee is not None:
|
||
resultat = [t for t in resultat if t.terminee == terminee]
|
||
if recherche:
|
||
resultat = [t for t in resultat if recherche.lower() in t.titre.lower()]
|
||
# les plus prioritaires d'abord (3 = haute), puis par ordre de création
|
||
return sorted(resultat, key=lambda t: (-t.priorite, t.id))
|
||
|
||
|
||
@app.post("/taches", status_code=201)
|
||
def creer_tache(nouvelle: NouvelleTache) -> Tache:
|
||
global prochain_id
|
||
tache = Tache(id=prochain_id, **nouvelle.model_dump())
|
||
taches[tache.id] = tache
|
||
prochain_id += 1
|
||
return tache
|
||
|
||
|
||
@app.patch("/taches/{tache_id}/terminer")
|
||
def terminer_tache(tache_id: int) -> Tache:
|
||
if tache_id not in taches:
|
||
raise HTTPException(status_code=404, detail="Tâche introuvable")
|
||
taches[tache_id].terminee = True
|
||
return taches[tache_id]
|
||
|
||
|
||
@app.get("/stats")
|
||
def statistiques() -> dict[str, int | float]:
|
||
total = len(taches)
|
||
terminees = sum(1 for t in taches.values() if t.terminee)
|
||
return {
|
||
"total": total,
|
||
"terminees": terminees,
|
||
"restantes": total - terminees,
|
||
"pourcentage": round(100 * terminees / total, 1) if total else 0.0,
|
||
}
|
||
```
|
||
|
||
`test_api_taches_ex.py` :
|
||
|
||
```python
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
import api_taches_ex
|
||
from api_taches_ex import app
|
||
|
||
client = TestClient(app)
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def reinitialiser():
|
||
"""Vide les tâches avant chaque test (autouse : appliquée automatiquement)."""
|
||
api_taches_ex.taches.clear()
|
||
api_taches_ex.prochain_id = 1
|
||
|
||
|
||
def creer(titre, priorite=1):
|
||
return client.post("/taches", json={"titre": titre, "priorite": priorite})
|
||
|
||
|
||
def test_stats_vides():
|
||
assert client.get("/stats").json() == {
|
||
"total": 0,
|
||
"terminees": 0,
|
||
"restantes": 0,
|
||
"pourcentage": 0.0,
|
||
}
|
||
|
||
|
||
def test_tri_et_recherche():
|
||
creer("Faire les courses", 1)
|
||
creer("Réviser Python", 3)
|
||
creer("Courses de Noël", 2)
|
||
titres = [t["titre"] for t in client.get("/taches").json()]
|
||
assert titres == ["Réviser Python", "Courses de Noël", "Faire les courses"]
|
||
trouvees = client.get("/taches", params={"recherche": "COURSES"}).json()
|
||
assert len(trouvees) == 2
|
||
|
||
|
||
def test_stats():
|
||
creer("A")
|
||
creer("B")
|
||
creer("C")
|
||
client.patch("/taches/2/terminer")
|
||
stats = client.get("/stats").json()
|
||
assert stats == {"total": 3, "terminees": 1, "restantes": 2, "pourcentage": 33.3}
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"donnees",
|
||
[{"titre": ""}, {"titre": "Ok", "priorite": 5}, {"titre": "Ok", "priorite": 0}],
|
||
)
|
||
def test_validation(donnees):
|
||
assert client.post("/taches", json=donnees).status_code == 422
|
||
```
|
||
|
||
Résultat : `6 passed`.
|
||
|
||
**Explications** :
|
||
|
||
- `Field(...)` ajoute des contraintes que pydantic vérifie automatiquement : aucune ligne de validation manuelle, et la documentation `/docs` les affiche.
|
||
- Le tri `key=lambda t: (-t.priorite, t.id)` ([chapitre 06](06-chaines-et-comprehensions.md)) : Python compare les tuples élément par élément ; le signe `-` inverse l'ordre des priorités (décroissant) tout en gardant les `id` croissants.
|
||
- `if total else 0.0` évite la **division par zéro** quand il n'y a aucune tâche (cas testé par `test_stats_vides`).
|
||
- La fixture `autouse=True` garantit l'**indépendance des tests** ([chapitre 13](13-tests-pytest-bonnes-pratiques.md)) : sans elle, les tâches créées par un test seraient visibles dans le suivant, et les `id` ne recommenceraient pas à 1. On modifie le module (`api_taches_ex.prochain_id = 1`) et non une copie importée.
|
||
- Dans une vraie application, on stockerait les tâches dans une base de données, et cette fixture créerait une base de test vide.
|
||
|
||
</details>
|
||
|
||
<details>
|
||
<summary>Corrigé de l'exercice 4</summary>
|
||
|
||
```python
|
||
"""Exercice 4 : répondre à des questions avec pandas."""
|
||
|
||
import pandas as pd
|
||
|
||
ventes = pd.read_csv("ventes.csv", parse_dates=["date"])
|
||
ventes["chiffre_affaires"] = ventes["quantite"] * ventes["prix_unitaire"]
|
||
|
||
# 1. Le produit le plus vendu en quantité
|
||
quantites = ventes.groupby("produit")["quantite"].sum()
|
||
print(f"1. Produit le plus vendu : {quantites.idxmax()} ({quantites.max()} unités)")
|
||
|
||
# 2. Chiffre d'affaires de chaque ville
|
||
print("2. Chiffre d'affaires par ville :")
|
||
print(ventes.groupby("ville")["chiffre_affaires"].sum().round(2).to_string())
|
||
|
||
# 3. La vente (ligne) qui a rapporté le plus
|
||
meilleure = ventes.loc[ventes["chiffre_affaires"].idxmax()]
|
||
print(
|
||
f"3. Meilleure vente : {meilleure['produit']} le {meilleure['date']:%d/%m/%Y} "
|
||
f"à {meilleure['ville']} ({meilleure['chiffre_affaires']:.2f} €)"
|
||
)
|
||
|
||
# 4. Les ventes de viennoiseries à Lyon de plus de 100 unités
|
||
filtre = (
|
||
(ventes["categorie"] == "Viennoiserie")
|
||
& (ventes["ville"] == "Lyon")
|
||
& (ventes["quantite"] > 100)
|
||
)
|
||
print("4. Viennoiseries à Lyon, plus de 100 unités :")
|
||
print(ventes.loc[filtre, ["date", "produit", "quantite"]].to_string(index=False))
|
||
|
||
# 5. Prix moyen pondéré par catégorie (CA total / quantité totale)
|
||
resume = ventes.groupby("categorie")[["chiffre_affaires", "quantite"]].sum()
|
||
resume["prix_moyen"] = (resume["chiffre_affaires"] / resume["quantite"]).round(2)
|
||
print("5. Prix moyen par catégorie :")
|
||
print(resume.sort_values("prix_moyen", ascending=False).to_string())
|
||
|
||
# 6. Exporter un résumé par produit
|
||
resume_produits = ventes.groupby("produit").agg(
|
||
quantite_totale=("quantite", "sum"),
|
||
chiffre_affaires=("chiffre_affaires", "sum"),
|
||
nombre_ventes=("date", "count"),
|
||
)
|
||
resume_produits.sort_values("chiffre_affaires", ascending=False).to_csv(
|
||
"resume_produits.csv"
|
||
)
|
||
print("6. Résumé exporté dans resume_produits.csv")
|
||
```
|
||
|
||
Sortie :
|
||
|
||
```text
|
||
1. Produit le plus vendu : Baguette (1270 unités)
|
||
2. Chiffre d'affaires par ville :
|
||
ville
|
||
Lyon 1680.0
|
||
Paris 1336.0
|
||
3. Meilleure vente : Baguette le 13/04/2026 à Lyon (360.00 €)
|
||
4. Viennoiseries à Lyon, plus de 100 unités :
|
||
date produit quantite
|
||
2026-01-05 Croissant 120
|
||
2026-02-23 Pain au chocolat 110
|
||
2026-03-16 Croissant 150
|
||
5. Prix moyen par catégorie :
|
||
chiffre_affaires quantite prix_moyen
|
||
categorie
|
||
Pâtisserie 458.5 150 3.06
|
||
Pain 1671.0 1340 1.25
|
||
Viennoiserie 886.5 745 1.19
|
||
6. Résumé exporté dans resume_produits.csv
|
||
```
|
||
|
||
`resume_produits.csv` :
|
||
|
||
```text
|
||
produit,quantite_totale,chiffre_affaires,nombre_ventes
|
||
Baguette,1270,1524.0,5
|
||
Croissant,410,451.0,3
|
||
Pain au chocolat,335,435.5,3
|
||
Éclair,95,266.0,2
|
||
Tarte aux pommes,55,192.5,2
|
||
Pain complet,70,147.0,1
|
||
```
|
||
|
||
**Explications** :
|
||
|
||
- `idxmax()` renvoie l'**étiquette** du maximum : le nom du produit pour une Series groupée (question 1), le numéro de ligne pour une colonne du DataFrame (question 3), qu'on passe ensuite à `.loc[...]`.
|
||
- Une date pandas accepte les formats de `strftime` dans une f-string : `{date:%d/%m/%Y}`.
|
||
- Question 4 : `df.loc[filtre, [colonnes]]` filtre les lignes **et** choisit les colonnes en une fois. N'oublie pas les parenthèses autour de chaque condition.
|
||
- Question 5 : la moyenne des prix unitaires (`mean()`) serait fausse, car elle ne tient pas compte des quantités vendues. Il faut diviser le total des euros par le total des articles : c'est une **moyenne pondérée**.
|
||
- `.agg(nom=("colonne", "fonction"))` (« agrégation nommée ») crée un résumé propre avec des noms de colonnes choisis.
|
||
|
||
</details>
|
||
|
||
<details>
|
||
<summary>Corrigé de l'exercice 5</summary>
|
||
|
||
```python
|
||
"""Exercice 5 : un graphique soigné du chiffre d'affaires par produit."""
|
||
|
||
import matplotlib.pyplot as plt
|
||
import pandas as pd
|
||
|
||
ventes = pd.read_csv("ventes.csv", parse_dates=["date"])
|
||
ventes["chiffre_affaires"] = ventes["quantite"] * ventes["prix_unitaire"]
|
||
par_produit = ventes.groupby("produit")["chiffre_affaires"].sum().sort_values()
|
||
|
||
figure, axe = plt.subplots(figsize=(8, 4.5))
|
||
barres = axe.barh(par_produit.index, par_produit.values, color="#4C72B0")
|
||
axe.bar_label(barres, labels=[f"{v:.0f} €" for v in par_produit.values], padding=4)
|
||
axe.set_title("Chiffre d'affaires par produit (janvier à avril 2026)")
|
||
axe.set_xlabel("Euros")
|
||
axe.spines[["top", "right"]].set_visible(False) # allège le cadre
|
||
axe.set_xlim(0, par_produit.max() * 1.15) # laisse de la place aux étiquettes
|
||
figure.tight_layout()
|
||
figure.savefig("ca_par_produit.png", dpi=100)
|
||
print("Graphique enregistré dans ca_par_produit.png")
|
||
```
|
||
|
||
Le graphique montre six barres horizontales, de « Baguette » (1524 €) en haut à « Pain complet » (147 €) en bas, chacune avec son montant à droite.
|
||
|
||
**Explications** :
|
||
|
||
- `barh` dessine la **première** valeur **en bas**. On trie donc par ordre **croissant** (`sort_values()`) pour que le plus grand se retrouve en haut.
|
||
- Les barres horizontales sont idéales quand les noms de catégories sont longs (« Pain au chocolat », « Tarte aux pommes ») : ils restent lisibles sans rotation.
|
||
- `set_xlim(0, max * 1.15)` élargit l'axe de 15 % pour que l'étiquette de la plus grande barre ne sorte pas du cadre.
|
||
- Un bon graphique dit **une chose clairement** : un titre explicite, une seule couleur (la couleur ne porte pas d'information ici), des valeurs lisibles, pas d'ornements inutiles.
|
||
|
||
</details>
|
||
|
||
<details>
|
||
<summary>Corrigé de l'exercice 6</summary>
|
||
|
||
```python
|
||
"""Exercice 6 : classer des vins, comparer des modèles et choisir k."""
|
||
|
||
from sklearn.datasets import load_wine
|
||
from sklearn.model_selection import cross_val_score, train_test_split
|
||
from sklearn.neighbors import KNeighborsClassifier
|
||
from sklearn.pipeline import make_pipeline
|
||
from sklearn.preprocessing import StandardScaler
|
||
|
||
vins = load_wine()
|
||
X, y = vins.data, vins.target
|
||
print(f"{X.shape[0]} vins, {X.shape[1]} mesures, {len(vins.target_names)} classes")
|
||
|
||
X_entrainement, X_test, y_entrainement, y_test = train_test_split(
|
||
X, y, test_size=0.25, random_state=42, stratify=y
|
||
)
|
||
|
||
# 1. Sans mise à l'échelle
|
||
brut = KNeighborsClassifier(n_neighbors=5)
|
||
brut.fit(X_entrainement, y_entrainement)
|
||
print(f"k-NN sans mise à l'échelle : {brut.score(X_test, y_test):.1%}")
|
||
|
||
# 2. Avec mise à l'échelle : toutes les mesures ramenées à la même échelle
|
||
normalise = make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=5))
|
||
normalise.fit(X_entrainement, y_entrainement)
|
||
print(f"k-NN avec StandardScaler : {normalise.score(X_test, y_test):.1%}")
|
||
|
||
# 3. Choisir k par validation croisée (sur les données d'entraînement seulement)
|
||
print("\nValidation croisée (5 plis) :")
|
||
meilleur_k, meilleur_score = None, 0.0
|
||
for k in range(1, 16, 2):
|
||
modele = make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=k))
|
||
scores = cross_val_score(modele, X_entrainement, y_entrainement, cv=5)
|
||
print(f" k = {k:2d} : {scores.mean():.1%} (± {scores.std():.1%})")
|
||
if scores.mean() > meilleur_score:
|
||
meilleur_k, meilleur_score = k, scores.mean()
|
||
|
||
final = make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=meilleur_k))
|
||
final.fit(X_entrainement, y_entrainement)
|
||
precision_finale = final.score(X_test, y_test)
|
||
print(f"\nMeilleur k = {meilleur_k} → précision sur le test : {precision_finale:.1%}")
|
||
```
|
||
|
||
Sortie (les chiffres peuvent varier légèrement selon les versions) :
|
||
|
||
```text
|
||
178 vins, 13 mesures, 3 classes
|
||
k-NN sans mise à l'échelle : 77.8%
|
||
k-NN avec StandardScaler : 93.3%
|
||
|
||
Validation croisée (5 plis) :
|
||
k = 1 : 95.4% (± 4.4%)
|
||
k = 3 : 96.2% (± 2.4%)
|
||
k = 5 : 95.5% (± 3.6%)
|
||
k = 7 : 96.2% (± 2.3%)
|
||
k = 9 : 96.2% (± 2.3%)
|
||
k = 11 : 97.0% (± 1.5%)
|
||
k = 13 : 97.0% (± 1.5%)
|
||
k = 15 : 97.7% (± 1.9%)
|
||
|
||
Meilleur k = 15 → précision sur le test : 100.0%
|
||
```
|
||
|
||
**Explications** :
|
||
|
||
- **Pourquoi la mise à l'échelle change tout ?** Le k-NN mesure des **distances** entre vins. Or une des mesures (la proline) vaut plusieurs centaines, alors que d'autres valent moins de 1. Sans mise à l'échelle, la proline « écrase » toutes les autres dans le calcul de distance : c'est comme comparer des maisons en mélangeant des surfaces en m² et des prix en centimes. `StandardScaler` ramène chaque mesure à une moyenne de 0 et un écart-type de 1.
|
||
- Le **pipeline** enchaîne la mise à l'échelle et le modèle. Avantage crucial : le `StandardScaler` apprend ses moyennes **uniquement sur les données d'entraînement**, sans « tricher » en regardant le jeu de test.
|
||
- **Validation croisée** : l'entraînement est coupé en 5 parts ; on entraîne sur 4 et on évalue sur la 5ᵉ, cinq fois de suite. La moyenne est bien plus fiable qu'un seul découpage, et l'écart-type (±) indique la stabilité.
|
||
- On choisit `k` **sans toucher au jeu de test**, qu'on n'utilise qu'**une seule fois** à la fin. Si on choisissait `k` d'après le jeu de test, on « apprendrait » indirectement sur lui et l'évaluation finale serait trop optimiste.
|
||
- Les écarts entre k = 3 et k = 15 sont faibles (de l'ordre de l'écart-type) : sur si peu de données, plusieurs valeurs de `k` se valent. Les 100 % finaux portent sur 45 vins seulement : ne t'emballe pas !
|
||
|
||
</details>
|
||
|
||
<details>
|
||
<summary>Corrigé de l'exercice 7</summary>
|
||
|
||
```python
|
||
"""Renomme en lot les fichiers d'un dossier : prefixe_001.ext, prefixe_002.ext…
|
||
|
||
Usage : python renommer_lot.py dossier prefixe [--extension .jpg] [--executer]
|
||
"""
|
||
|
||
import argparse
|
||
from pathlib import Path
|
||
|
||
|
||
def planifier(
|
||
dossier: Path, prefixe: str, extension: str | None
|
||
) -> list[tuple[Path, Path]]:
|
||
"""Renvoie la liste des renommages (ancien, nouveau) sans rien modifier."""
|
||
fichiers = sorted(
|
||
f
|
||
for f in dossier.iterdir()
|
||
if f.is_file()
|
||
and not f.name.startswith(".")
|
||
and (extension is None or f.suffix.lower() == extension.lower())
|
||
)
|
||
largeur = max(3, len(str(len(fichiers)))) # 001, 002… ou 0001 si > 999 fichiers
|
||
plan = []
|
||
for numero, fichier in enumerate(fichiers, start=1):
|
||
nouveau_nom = f"{prefixe}_{numero:0{largeur}d}{fichier.suffix.lower()}"
|
||
plan.append((fichier, fichier.with_name(nouveau_nom)))
|
||
return plan
|
||
|
||
|
||
def main() -> None:
|
||
analyseur = argparse.ArgumentParser(description="Renommage en lot.")
|
||
analyseur.add_argument("dossier", type=Path)
|
||
analyseur.add_argument("prefixe")
|
||
analyseur.add_argument("--extension", help="ne traiter que cette extension")
|
||
analyseur.add_argument("--executer", action="store_true")
|
||
args = analyseur.parse_args()
|
||
|
||
plan = planifier(args.dossier, args.prefixe, args.extension)
|
||
nouveaux = {nouveau for _, nouveau in plan}
|
||
anciens = {ancien for ancien, _ in plan}
|
||
conflits = [n for n in nouveaux if n.exists() and n not in anciens]
|
||
if conflits:
|
||
analyseur.error(f"ces fichiers existent déjà : {[c.name for c in conflits]}")
|
||
|
||
etiquette = "✔" if args.executer else "[simulation]"
|
||
for ancien, nouveau in plan:
|
||
print(f"{etiquette} {ancien.name} → {nouveau.name}")
|
||
if args.executer:
|
||
# Deux passes : d'abord des noms temporaires, pour éviter d'écraser
|
||
# un fichier qui porterait déjà un des nouveaux noms (ex. photo_002 ↔ photo_001)
|
||
temporaires = []
|
||
for ancien, nouveau in plan:
|
||
temporaire = ancien.with_name(f".tmp_{nouveau.name}")
|
||
ancien.rename(temporaire)
|
||
temporaires.append((temporaire, nouveau))
|
||
for temporaire, nouveau in temporaires:
|
||
temporaire.rename(nouveau)
|
||
print(f"{len(plan)} fichier(s) concerné(s).")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|
||
```
|
||
|
||
Essai avec un dossier piégeux (`IMG_2034.JPG`, `b.jpg`, `vacances_002.jpg`, `x.txt`) :
|
||
|
||
```text
|
||
$ python renommer_lot.py photos vacances --extension .jpg --executer
|
||
✔ IMG_2034.JPG → vacances_001.jpg
|
||
✔ b.jpg → vacances_002.jpg
|
||
✔ vacances_002.jpg → vacances_003.jpg
|
||
3 fichier(s) concerné(s).
|
||
```
|
||
|
||
Le fichier `b.jpg` devient `vacances_002.jpg` **alors que ce nom est déjà pris** par un fichier qui doit lui-même devenir `vacances_003.jpg`. Avec un renommage direct, `b.jpg` aurait écrasé l'ancien `vacances_002.jpg` ! (Sous Windows, `rename` aurait levé une erreur ; sous macOS/Linux, il écrase **silencieusement**.)
|
||
|
||
**Explications** :
|
||
|
||
- **Séparer planification et exécution** : `planifier` calcule tout sans rien toucher (facile à tester et à afficher en simulation), `main` exécute.
|
||
- **Deux passes** : on renomme d'abord tout en noms temporaires cachés (`.tmp_...`), puis vers les noms finaux. À la deuxième passe, plus aucun ancien nom ne peut entrer en collision.
|
||
- **Vérification préalable** : si un nom cible existe déjà et ne fait **pas** partie des fichiers à renommer (par exemple, avec `--extension .png`, un fichier `vacances_001.png` qui existe déjà mais serait ignoré car caché, ou un dossier portant ce nom), on s'arrête avant de toucher à quoi que ce soit.
|
||
- `{numero:0{largeur}d}` : formatage avec des zéros devant, sur une largeur variable (`001`). Les numéros à largeur fixe gardent l'ordre alphabétique cohérent (`010` après `009`, alors que `10` serait avant `9`).
|
||
- Relancer le script donne le même résultat (`vacances_001.jpg → vacances_001.jpg`…) : il est **idempotent**, une qualité précieuse en automatisation.
|
||
|
||
</details>
|
||
|
||
<details>
|
||
<summary>Corrigé de l'exercice 8</summary>
|
||
|
||
```python
|
||
"""Attrape-étoiles, version 2 : niveaux, étoiles piégées, rejouer, meilleur score."""
|
||
|
||
import random
|
||
from pathlib import Path
|
||
|
||
import pygame
|
||
|
||
LARGEUR, HAUTEUR = 640, 480
|
||
IPS = 60
|
||
BLANC = (255, 255, 255)
|
||
NUIT = (15, 20, 45)
|
||
JAUNE = (255, 215, 0)
|
||
ROUGE = (230, 60, 60)
|
||
ORANGE = (240, 130, 40)
|
||
FICHIER_RECORD = Path("record.txt")
|
||
|
||
|
||
def lire_record() -> int:
|
||
try:
|
||
return int(FICHIER_RECORD.read_text(encoding="utf-8"))
|
||
except (FileNotFoundError, ValueError):
|
||
return 0
|
||
|
||
|
||
def ecrire_record(score: int) -> None:
|
||
FICHIER_RECORD.write_text(str(score), encoding="utf-8")
|
||
|
||
|
||
class Panier:
|
||
def __init__(self) -> None:
|
||
self.rect = pygame.Rect(0, 0, 100, 20)
|
||
self.rect.midbottom = (LARGEUR // 2, HAUTEUR - 20)
|
||
self.vitesse = 7
|
||
|
||
def deplacer(self, touches) -> None:
|
||
if touches[pygame.K_LEFT]:
|
||
self.rect.x -= self.vitesse
|
||
if touches[pygame.K_RIGHT]:
|
||
self.rect.x += self.vitesse
|
||
self.rect.clamp_ip(pygame.Rect(0, 0, LARGEUR, HAUTEUR))
|
||
|
||
def dessiner(self, ecran) -> None:
|
||
pygame.draw.rect(ecran, ORANGE, self.rect, border_radius=6)
|
||
|
||
|
||
class Etoile:
|
||
def __init__(self, niveau: int) -> None:
|
||
self.rect = pygame.Rect(random.randint(0, LARGEUR - 20), -20, 20, 20)
|
||
# plus le niveau est élevé, plus les étoiles tombent vite
|
||
self.vitesse = random.uniform(2, 4) + 0.5 * niveau
|
||
self.y = float(self.rect.y)
|
||
self.piegee = random.random() < 0.2 # 1 étoile sur 5 est rouge
|
||
|
||
def tomber(self) -> None:
|
||
self.y += self.vitesse
|
||
self.rect.y = int(self.y)
|
||
|
||
def dessiner(self, ecran) -> None:
|
||
couleur = ROUGE if self.piegee else JAUNE
|
||
pygame.draw.circle(ecran, couleur, self.rect.center, 10)
|
||
|
||
|
||
class Jeu:
|
||
def __init__(self) -> None:
|
||
self.record = lire_record()
|
||
self.recommencer()
|
||
|
||
def recommencer(self) -> None:
|
||
self.panier = Panier()
|
||
self.etoiles: list[Etoile] = []
|
||
self.score = 0
|
||
self.vies = 3
|
||
|
||
@property
|
||
def niveau(self) -> int:
|
||
return 1 + self.score // 10 # un niveau de plus toutes les 10 étoiles
|
||
|
||
@property
|
||
def termine(self) -> bool:
|
||
return self.vies <= 0
|
||
|
||
def perdre_une_vie(self) -> None:
|
||
self.vies -= 1
|
||
if self.termine and self.score > self.record:
|
||
self.record = self.score
|
||
ecrire_record(self.record)
|
||
|
||
def mettre_a_jour(self, touches) -> None:
|
||
if self.termine:
|
||
return
|
||
self.panier.deplacer(touches)
|
||
if random.random() < 0.02 + 0.005 * self.niveau:
|
||
self.etoiles.append(Etoile(self.niveau))
|
||
for etoile in self.etoiles[:]:
|
||
etoile.tomber()
|
||
if etoile.rect.colliderect(self.panier.rect):
|
||
self.etoiles.remove(etoile)
|
||
if etoile.piegee:
|
||
self.perdre_une_vie()
|
||
else:
|
||
self.score += 1
|
||
elif etoile.rect.top > HAUTEUR:
|
||
self.etoiles.remove(etoile)
|
||
if not etoile.piegee: # rater une étoile rouge, c'est bien !
|
||
self.perdre_une_vie()
|
||
|
||
def dessiner(self, ecran, police) -> None:
|
||
ecran.fill(NUIT)
|
||
for etoile in self.etoiles:
|
||
etoile.dessiner(ecran)
|
||
self.panier.dessiner(ecran)
|
||
infos = (
|
||
f"Score : {self.score} Vies : {self.vies} "
|
||
f"Niveau : {self.niveau} Record : {self.record}"
|
||
)
|
||
ecran.blit(police.render(infos, True, BLANC), (10, 10))
|
||
if self.termine:
|
||
fin = police.render(
|
||
"Perdu ! R pour rejouer, Échap pour quitter", True, BLANC
|
||
)
|
||
ecran.blit(fin, fin.get_rect(center=(LARGEUR // 2, HAUTEUR // 2)))
|
||
|
||
|
||
def main() -> None:
|
||
pygame.init()
|
||
ecran = pygame.display.set_mode((LARGEUR, HAUTEUR))
|
||
pygame.display.set_caption("Attrape-étoiles v2")
|
||
horloge = pygame.time.Clock()
|
||
police = pygame.font.Font(None, 30)
|
||
jeu = Jeu()
|
||
|
||
en_cours = True
|
||
while en_cours:
|
||
for evenement in pygame.event.get():
|
||
if evenement.type == pygame.QUIT:
|
||
en_cours = False
|
||
elif evenement.type == pygame.KEYDOWN:
|
||
if evenement.key == pygame.K_ESCAPE:
|
||
en_cours = False
|
||
elif evenement.key == pygame.K_r and jeu.termine:
|
||
jeu.recommencer()
|
||
|
||
jeu.mettre_a_jour(pygame.key.get_pressed())
|
||
jeu.dessiner(ecran, police)
|
||
pygame.display.flip()
|
||
horloge.tick(IPS)
|
||
|
||
pygame.quit()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|
||
```
|
||
|
||
**Explications** :
|
||
|
||
- La classe `Jeu` rassemble tout l'**état** de la partie. `recommencer()` le remet à zéro : rejouer devient trivial, et `__init__` l'appelle pour ne pas dupliquer le code.
|
||
- `niveau` et `termine` sont des `@property` : ils sont **calculés** à partir du score et des vies, donc jamais désynchronisés. On écrit `jeu.niveau` comme un simple attribut.
|
||
- La boucle `main` est maintenant très courte et lisible : les trois étapes (événements, mise à jour, affichage) sont clairement séparées.
|
||
- **Deux façons de lire le clavier** : `KEYDOWN` pour une action ponctuelle (rejouer, quitter : une seule fois par appui), `get_pressed()` pour un mouvement continu.
|
||
- La difficulté augmente doucement : vitesse `+ 0.5 * niveau` et probabilité d'apparition `0.02 + 0.005 * niveau`. Équilibrer un jeu demande beaucoup d'essais : n'hésite pas à ajuster ces nombres (c'est pour ça qu'ils sont faciles à repérer).
|
||
- `lire_record` gère deux erreurs : le fichier n'existe pas encore (premier lancement), ou il contient autre chose qu'un nombre ([chapitre 08](08-erreurs-et-debogage.md)). Dans les deux cas, on repart de 0 au lieu de planter.
|
||
- La logique (`mettre_a_jour`, `niveau`, `perdre_une_vie`) est séparée du dessin : on pourrait la tester avec pytest sans ouvrir de fenêtre.
|
||
|
||
</details>
|
||
|
||
<details>
|
||
<summary>Corrigé du mini-projet</summary>
|
||
|
||
Structure du projet :
|
||
|
||
```text
|
||
station-meteo/
|
||
├── .gitignore
|
||
├── requirements.txt
|
||
├── generer_donnees.py
|
||
├── analyse.py
|
||
├── app.py
|
||
├── test_meteo.py
|
||
├── releves.csv (généré)
|
||
└── static/
|
||
└── mensuel.png (généré)
|
||
```
|
||
|
||
**`generer_donnees.py`** :
|
||
|
||
```python
|
||
"""Étape 1 (automatisation) : génère un fichier CSV de relevés météo fictifs."""
|
||
|
||
import csv
|
||
import math
|
||
import random
|
||
from datetime import date, timedelta
|
||
from pathlib import Path
|
||
|
||
VILLES = {"Brest": 12.0, "Lyon": 13.0, "Marseille": 16.0} # température moyenne
|
||
|
||
|
||
def generer(chemin: Path, debut: date, nombre_jours: int, graine: int = 1) -> None:
|
||
hasard = random.Random(graine) # graine fixe : mêmes données à chaque fois
|
||
with open(chemin, "w", newline="", encoding="utf-8") as fichier:
|
||
ecrivain = csv.writer(fichier)
|
||
ecrivain.writerow(["date", "ville", "temp_min", "temp_max", "pluie_mm"])
|
||
for i in range(nombre_jours):
|
||
jour = debut + timedelta(days=i)
|
||
# une sinusoïde imite les saisons : froid en janvier, chaud en juillet
|
||
saison = -8 * math.cos(2 * math.pi * (jour.timetuple().tm_yday - 15) / 365)
|
||
for ville, moyenne in VILLES.items():
|
||
centre = moyenne + saison + hasard.gauss(0, 2)
|
||
ecart = hasard.uniform(4, 10) # écart entre le min et le max du jour
|
||
temp_min = round(centre - ecart / 2, 1)
|
||
temp_max = round(centre + ecart / 2, 1)
|
||
il_pleut = hasard.random() < 0.35 # environ 1 jour sur 3
|
||
pluie = round(hasard.expovariate(0.4), 1) if il_pleut else 0
|
||
ecrivain.writerow([jour.isoformat(), ville, temp_min, temp_max, pluie])
|
||
|
||
|
||
if __name__ == "__main__":
|
||
generer(Path("releves.csv"), date(2025, 1, 1), 365)
|
||
print("releves.csv créé.")
|
||
```
|
||
|
||
**`analyse.py`** :
|
||
|
||
```python
|
||
"""Étape 2 (data) : analyse des relevés avec pandas et graphique matplotlib."""
|
||
|
||
from pathlib import Path
|
||
|
||
import matplotlib
|
||
|
||
matplotlib.use("Agg") # dessine dans un fichier, sans ouvrir de fenêtre
|
||
import matplotlib.pyplot as plt # noqa: E402
|
||
import pandas as pd # noqa: E402
|
||
|
||
|
||
def charger(chemin: Path) -> pd.DataFrame:
|
||
releves = pd.read_csv(chemin, parse_dates=["date"])
|
||
releves["temp_moyenne"] = (releves["temp_min"] + releves["temp_max"]) / 2
|
||
return releves
|
||
|
||
|
||
def resume_par_ville(releves: pd.DataFrame) -> pd.DataFrame:
|
||
return (
|
||
releves.groupby("ville")
|
||
.agg(
|
||
temp_moyenne=("temp_moyenne", "mean"),
|
||
record_chaleur=("temp_max", "max"),
|
||
record_froid=("temp_min", "min"),
|
||
jours_de_pluie=("pluie_mm", lambda pluie: int((pluie > 0).sum())),
|
||
cumul_pluie_mm=("pluie_mm", "sum"),
|
||
)
|
||
.round(1)
|
||
)
|
||
|
||
|
||
def tracer_mensuel(releves: pd.DataFrame, chemin_image: Path) -> None:
|
||
mensuel = releves.pivot_table(
|
||
index=releves["date"].dt.month,
|
||
columns="ville",
|
||
values="temp_moyenne",
|
||
aggfunc="mean",
|
||
)
|
||
figure, axe = plt.subplots(figsize=(8, 4))
|
||
mensuel.plot(ax=axe, marker="o")
|
||
axe.set_title("Température moyenne par mois")
|
||
axe.set_xlabel("Mois")
|
||
axe.set_ylabel("°C")
|
||
axe.set_xticks(range(1, 13))
|
||
axe.grid(alpha=0.3)
|
||
figure.tight_layout()
|
||
chemin_image.parent.mkdir(exist_ok=True)
|
||
figure.savefig(chemin_image, dpi=100)
|
||
plt.close(figure)
|
||
```
|
||
|
||
**`app.py`** :
|
||
|
||
```python
|
||
"""Étape 3 (web) : un tableau de bord Flask qui affiche l'analyse."""
|
||
|
||
from pathlib import Path
|
||
|
||
from flask import Flask, jsonify, render_template_string
|
||
|
||
from analyse import charger, resume_par_ville, tracer_mensuel
|
||
|
||
DOSSIER = Path(__file__).parent
|
||
FICHIER_CSV = DOSSIER / "releves.csv"
|
||
IMAGE = DOSSIER / "static" / "mensuel.png"
|
||
|
||
app = Flask(__name__)
|
||
|
||
PAGE = """
|
||
<!doctype html>
|
||
<html lang="fr">
|
||
<head><meta charset="utf-8"><title>Ma station météo</title></head>
|
||
<body style="font-family: sans-serif; max-width: 50rem; margin: 2rem auto;">
|
||
<h1>🌦️ Ma station météo</h1>
|
||
<p>{{ nombre }} relevés analysés.</p>
|
||
{{ tableau|safe }}
|
||
<img src="/static/mensuel.png" alt="Température moyenne par mois"
|
||
style="max-width: 100%;">
|
||
<p><a href="/api/resume">Données au format JSON</a></p>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
@app.route("/")
|
||
def tableau_de_bord():
|
||
releves = charger(FICHIER_CSV)
|
||
tracer_mensuel(releves, IMAGE)
|
||
tableau = resume_par_ville(releves).to_html(border=0)
|
||
return render_template_string(PAGE, nombre=len(releves), tableau=tableau)
|
||
|
||
|
||
@app.route("/api/resume")
|
||
def api_resume():
|
||
resume = resume_par_ville(charger(FICHIER_CSV))
|
||
return jsonify(resume.to_dict(orient="index"))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
app.run(debug=True)
|
||
```
|
||
|
||
**`test_meteo.py`** :
|
||
|
||
```python
|
||
from datetime import date
|
||
|
||
import pytest
|
||
|
||
from analyse import charger, resume_par_ville, tracer_mensuel
|
||
from generer_donnees import generer
|
||
|
||
|
||
@pytest.fixture
|
||
def fichier_csv(tmp_path):
|
||
chemin = tmp_path / "releves.csv"
|
||
generer(chemin, date(2025, 1, 1), 60)
|
||
return chemin
|
||
|
||
|
||
def test_generer_nombre_de_lignes(fichier_csv):
|
||
lignes = fichier_csv.read_text(encoding="utf-8").splitlines()
|
||
assert len(lignes) == 1 + 60 * 3 # en-tête + 60 jours × 3 villes
|
||
|
||
|
||
def test_min_inferieur_au_max(fichier_csv):
|
||
releves = charger(fichier_csv)
|
||
assert (releves["temp_min"] <= releves["temp_max"]).all()
|
||
|
||
|
||
def test_resume(fichier_csv):
|
||
resume = resume_par_ville(charger(fichier_csv))
|
||
assert sorted(resume.index) == ["Brest", "Lyon", "Marseille"]
|
||
assert (resume["jours_de_pluie"] <= 60).all()
|
||
assert (resume["record_froid"] <= resume["record_chaleur"]).all()
|
||
|
||
|
||
def test_graphique_cree(fichier_csv, tmp_path):
|
||
image = tmp_path / "images" / "mensuel.png"
|
||
tracer_mensuel(charger(fichier_csv), image)
|
||
assert image.exists() and image.stat().st_size > 0
|
||
|
||
|
||
def test_application_web(fichier_csv, monkeypatch):
|
||
import app as module_app
|
||
|
||
monkeypatch.setattr(module_app, "FICHIER_CSV", fichier_csv)
|
||
client = module_app.app.test_client()
|
||
assert "Ma station météo" in client.get("/").get_data(as_text=True)
|
||
assert set(client.get("/api/resume").get_json()) == {"Brest", "Lyon", "Marseille"}
|
||
```
|
||
|
||
**Utilisation** :
|
||
|
||
```bash
|
||
python generer_donnees.py # crée releves.csv (365 jours × 3 villes)
|
||
pytest # 5 passed
|
||
python app.py # puis ouvre http://127.0.0.1:5000
|
||
```
|
||
|
||
Extrait de `releves.csv` et de la réponse de `/api/resume` :
|
||
|
||
```text
|
||
date,ville,temp_min,temp_max,pluie_mm
|
||
2025-01-01,Brest,2.5,11.1,1.7
|
||
2025-01-01,Lyon,4.8,11.5,0
|
||
2025-01-01,Marseille,6.4,10.5,0
|
||
```
|
||
|
||
```text
|
||
"Lyon": {"cumul_pluie_mm": 310.9, "jours_de_pluie": 114, "record_chaleur": 29.3,
|
||
"record_froid": -3.6, "temp_moyenne": 13.2}
|
||
```
|
||
|
||
Le graphique montre trois courbes en cloche (minimum en janvier, maximum en juillet), Marseille au-dessus, Brest en dessous.
|
||
|
||
**Points clés de la correction** :
|
||
|
||
- **Reproductibilité** : `random.Random(graine)` crée un générateur indépendant ; même graine → mêmes données. Indispensable pour des tests fiables et pour comparer des analyses.
|
||
- `gauss(0, 2)` ajoute des variations « naturelles » (la plupart proches de 0, rarement au-delà de ±4 °C) ; `expovariate(0.4)` donne beaucoup de petites pluies et quelques grosses averses (moyenne 2,5 mm), comme dans la réalité.
|
||
- `temp_min = centre - ecart/2` et `temp_max = centre + ecart/2` avec `ecart` positif : la règle `temp_min <= temp_max` est **garantie par construction**, et un test le vérifie quand même.
|
||
- L'agrégation `jours_de_pluie` utilise une `lambda` ([chapitre 06](06-chaines-et-comprehensions.md)) : `(pluie > 0).sum()` compte les `True`.
|
||
- `matplotlib.use("Agg")` **avant** d'importer `pyplot` : un serveur web n'a pas d'écran, et ouvrir des fenêtres y provoquerait des erreurs. Les commentaires `# noqa: E402` disent à ruff que les imports placés après ce réglage sont volontaires.
|
||
- `{{ tableau|safe }}` : on désactive l'échappement **uniquement** parce que ce HTML vient de pandas, pas d'un utilisateur.
|
||
- `Path(__file__).parent` : les chemins sont calculés par rapport au fichier `app.py`, donc l'application fonctionne quel que soit le dossier depuis lequel on la lance.
|
||
- `monkeypatch.setattr` (fixture intégrée de pytest) remplace **temporairement** `FICHIER_CSV` par le fichier de test : l'application est testée sans toucher aux vraies données. Remarque : la page `/` régénère bien l'image dans `static/` du projet ; pour un projet plus abouti, on rendrait aussi ce chemin configurable.
|
||
- Chaque module a **une seule responsabilité** (produire, analyser, afficher) : c'est ce qui rend l'ensemble lisible et testable.
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
## Récapitulatif
|
||
|
||
**Points clés à retenir :**
|
||
|
||
- **Automatisation** : `pathlib` + `shutil` + `argparse` transforment une corvée en commande. Toujours un mode simulation, tester sur une copie, ne jamais écraser.
|
||
- **Web** : un serveur associe des **routes** à des fonctions. **Flask** est simple et parfait pour débuter ; **FastAPI** valide les données grâce aux annotations de type et génère la documentation `/docs`. Les deux se testent avec un client de test.
|
||
- **Data** : **pandas** manipule des tableaux (`read_csv`, filtres, `groupby`, `pivot_table`) sans boucles ; **matplotlib** dessine les graphiques (`plt.subplots`, `savefig`, `plt.show`).
|
||
- **IA / ML** : **scikit-learn** offre une interface unique `fit` / `predict` / `score`. On sépare toujours les données (`train_test_split`) et on évalue sur des exemples jamais vus ; la validation croisée rend l'évaluation fiable.
|
||
- **Jeux** : tout jeu repose sur la **boucle de jeu** — événements, mise à jour, affichage — à 60 images par seconde. **pygame** fournit `Rect`, les collisions, le clavier et le dessin.
|
||
- Chaque domaine a ses bibliothèques, mais **les bases de Python sont les mêmes partout** : fonctions, classes, fichiers, tests, venv et Git te servent dans tous les cas.
|
||
- Choisis **un** domaine qui te motive et approfondis-le par des projets concrets.
|
||
|
||
**Checklist :**
|
||
|
||
- [ ] J'ai créé un venv dédié pour chaque domaine testé.
|
||
- [ ] J'ai lancé le script de tri de fichiers en mode simulation, puis pour de vrai sur un dossier de test.
|
||
- [ ] J'ai lancé l'application Flask et ouvert la page dans mon navigateur.
|
||
- [ ] J'ai exploré la documentation automatique `/docs` de l'API FastAPI.
|
||
- [ ] J'ai analysé `ventes.csv` avec pandas et obtenu un graphique matplotlib.
|
||
- [ ] J'ai entraîné et évalué un modèle scikit-learn sur le jeu iris.
|
||
- [ ] J'ai joué à Attrape-étoiles et je comprends la boucle de jeu.
|
||
- [ ] J'ai consulté le tableau comparatif et choisi le domaine que je veux approfondir.
|
||
- [ ] J'ai noté une idée de projet pour le chapitre suivant.
|
||
|
||
---
|
||
|
||
[← Chapitre précédent](13-tests-pytest-bonnes-pratiques.md) | [Sommaire](README.md) | [Chapitre suivant →](15-projet-final.md)
|