Aller au contenu

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, savoir créer un venv et installer des paquets (Chapitre 12), être à l'aise avec les fichiers (Chapitre 07) et les classes (Chapitre 10).

🎯 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.

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 :

"""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 :

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
# Windows PowerShell :
mkdir bazar
"vacances.JPG","cv.pdf","chanson.mp3","script.py","archive.zip","notes.txt","mystere.xyz" | ForEach-Object { New-Item "bazar\$_" }
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
[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) : 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).

⚠️ 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.

 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)
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)
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

pip install flask

Enregistre app_flask.py :

"""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 :

python app_flask.py
 * 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 :

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

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 :

"""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 :

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 :

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
# 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) 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) :

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
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) :

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 :

"""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()
python analyse_ventes.py

Extrait de la sortie :

        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 (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
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 :

"""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}")
python iris_classification.py
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
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 :

┌──────────────────────────────────────────────────┐
│  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) ! 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 :

"""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()
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) 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). 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) 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 :

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) 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 :
  3. charger(chemin) → DataFrame avec une colonne temp_moyenne en plus ;
  4. resume_par_ville(releves) → par ville : température moyenne, record de chaleur, record de froid, nombre de jours de pluie, cumul de pluie ;
  5. tracer_mensuel(releves, chemin_image) → graphique de la température moyenne par mois, une courbe par ville, enregistré en PNG.
  6. 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.
  7. 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.
  8. Qualité : venv, requirements.txt, .gitignore, ruff check et ruff format --check sans erreur, projet sous Git (chapitre 12).

É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

Corrigé de l'exercice 1
"""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 :
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.
Corrigé de l'exercice 2 `app_flask_ex.py` :
"""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` :
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** : - `` : 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.
Corrigé de l'exercice 3 `api_taches_ex.py` :
"""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` :
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.
Corrigé de l'exercice 4
"""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 :
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` :
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.
Corrigé de l'exercice 5
"""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.
Corrigé de l'exercice 6
"""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) :
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 !
Corrigé de l'exercice 7
"""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`) :
$ 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.
Corrigé de l'exercice 8
"""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.
Corrigé du mini-projet Structure du projet :
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`** :
"""É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`** :
"""É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`** :
"""É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`** :
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** :
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` :
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
"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.

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 | Sommaire | Chapitre suivant →