← Bibliothèque · MASTER · Mon projet portfolio
0/0 ⬇
Collection « Challenges » · Apprendre à coder en Python · Étudiants · Master

Mon projet portfolio — 4 mois pour concevoir, tester, publier et présenter un vrai projet Python

En quatre mois, tu conçois un vrai projet de portfolio : tu raisonnes sur les algorithmes, tu écris un code robuste et testé, tu construis une petite application avec une API et une base de données, tu la publies… et tu apprends à l'expliquer en entretien et à aider tes pairs.

32 épisodes · 4 mois2 épisodes par semaine (mardi et jeudi)Thonny, VS Code et terminal≈ 35 min / épisode
BOOST · 5 JOURSMaîtriser les bases et écrire un premier script utile pour tes cours.
PRO · 20 JOURSFonctions, données, fichiers, graphiques et Git pour réussir tes TP.
MASTER · 4 MOISConcevoir un projet testé et publié, raisonner sur les algorithmes, aider tes pairs.
Le fil rouge. Yanis, 18 ans, en 1re année de licence de sciences de la vie (personnage fictif), a réussi son TP d'analyse de données. Au printemps, il vise un stage d'été dans un laboratoire d'écologie végétale qui demande un projet Python personnel. Son idée : « Pousse », un petit logiciel qui analyse le cahier de mesures du jardin partagé de la fac.
Chaque semaine = un épisode « Comprendre » le mardi, un épisode « Pratiquer » le jeudi. Chaque arc se termine par un bilan et un badge.
Tes données restent chez toi. Ta progression et ton journal sont enregistrés seulement dans ce navigateur. Les données du projet sont inventées ; n'utilise jamais de données personnelles réelles.
Règle d'or. Un code que tu ne comprends pas, tu ne le lances pas… et tu ne le publies pas.
Ce que tu sais déjàTu écris, lances et répares un programme, avec conditions et boucles (Boost, épisodes 1 à 5). Tu écris des fonctions documentées et vérifiées (Pro, épisodes 1 et 2), tu mets en forme avec les f-strings (Pro, épisode 3) et tu débogues avec méthode (Pro, épisode 4). Tu ranges tes données dans des listes, des dictionnaires, des tuples et des ensembles (Pro, épisodes 6 et 8) et tu écris un code lisible (Pro, épisode 9). Tu utilises la bibliothèque standard et sa documentation (Pro, épisodes 11 et 12), tu lis et écris des fichiers CSV et JSON (Pro, épisode 13), tu travailles dans un environnement virtuel (Pro, épisode 14). Tu analyses avec pandas et matplotlib (Pro, épisodes 16 et 17), tu versionnes avec Git (Pro, épisode 18) et tu te fais aider par une IA en le déclarant (Pro, épisode 19). Tu démarres ici sans avoir suivi le Boost et le Pro ? Le kit de rattrapage est en fin de livre.
ARC 1 · SEMAINES 1 À 4

Penser algorithme

Avant de construire « Pousse », tu apprends à raisonner sur tes données : transformer une liste en une ligne, chercher et trier vite, confier un problème imbriqué à une fonction qui s'appelle elle-même, et estimer ce que coûte un algorithme avant de le lancer.

Semaine 1 · Les compréhensions
01
S1 · MARDI · Comprendre

Des boucles en une ligne

Écrire et lire des compréhensions de listes, de dictionnaires et d'ensembles, avec ou sans filtre, et savoir quand garder une boucle.

⏱ 30 min de lecture · 5 min de quiz
[1]Scénario
Yanis · Mardi, 17 h

Yanis a récupéré le cahier du jardin partagé : deux parcelles de radis, l'une paillée (A), l'autre nue (B), une mesure par semaine pendant douze semaines. Les hauteurs sont écrites avec une virgule, et deux cases disent « n.m. » (non mesuré) ou rien du tout. Dans le code d'un doctorant du laboratoire, il tombe sur [float(t.replace(",", ".")) for t in tailles if t != "n.m."]. Une ligne. Il ne comprend rien. Il aurait écrit six lignes.

[2]Concept

Les compréhensions

Une compréhension construit une nouvelle collection à partir d'une séquence, en une seule expression. C'est exactement la boucle for + append du Pro (Pro, épisode 6), écrite autrement.

La boucle que tu sais écrire :

Code
brut = ["8,0", "14,5", "22,0", "n.m.", "40,0"]
tailles = []
for texte in brut:
    if texte != "n.m.":
        tailles.append(float(texte.replace(",", ".")))
print(tailles)

La même, en compréhension :

Code
brut = ["8,0", "14,5", "22,0", "n.m.", "40,0"]
tailles = [float(texte.replace(",", ".")) for texte in brut if texte != "n.m."]
print(tailles)

Les deux affichent [8.0, 14.5, 22.0, 40.0]. Lis la compréhension dans cet ordre : pour chaque texte dans brut, si texte != "n.m.", garde float(texte.replace(",", ".")). Les morceaux sont ceux de la boucle, dans le même ordre, sauf l'expression gardée, qui passe devant.

Les trois formes.

Code
releves = [(1, 8.0), (2, 14.5), (3, 22.0), (4, 31.5)]
carres = [semaine ** 2 for semaine, hauteur in releves]
par_semaine = {semaine: hauteur for semaine, hauteur in releves}
dates = ["2026-03-30", "2026-04-06", "2026-04-13", "2026-05-04"]
mois = {date[:7] for date in dates}
print(carres)
print(par_semaine)
print(sorted(mois))
print([h for s, h in releves if h > 20])

Affiche :

Ce que tu vois
[1, 4, 9, 16] {1: 8.0, 2: 14.5, 3: 22.0, 4: 31.5} ['2026-03', '2026-04', '2026-05'] [22.0, 31.5]
FormeÉcritureRésultat
liste[expression for x in séquence]une liste, dans l'ordre
liste filtrée[expression for x in séquence if condition]seulement les éléments qui passent le filtre
dictionnaire{cle: valeur for x in séquence}un dictionnaire (Pro, épisode 8)
ensemble{expression for x in séquence}un ensemble, sans doublon ('2026-04' n'apparaît qu'une fois)

Le déballage des tuples (for semaine, hauteur in releves) fonctionne comme dans une boucle.

Deux pièges.

  • Si l'expression gardée est un tuple, il faut des parenthèses : [semaine, hauteur * 10 for semaine, hauteur in releves] s'arrête sur SyntaxError: did you forget parentheses around the comprehension target? (« as-tu oublié les parenthèses autour de la cible de la compréhension ? ») ; on écrit [(semaine, hauteur * 10) for …].
  • Une compréhension sert à construire une collection, pas à faire quelque chose. [print(t) for t in tailles] affiche bien les valeurs… et construit pour rien la liste [None, None, None] (ce que renvoie print). Pour afficher, écrire, modifier : une boucle.

Quand garder la boucle. Dès qu'il y a un affichage, une écriture dans un fichier, plusieurs étapes, ou une compréhension dans une compréhension : la boucle est plus lisible (Pro, épisode 9). Une compréhension se relit en une phrase ; si la phrase est trop longue, c'est le signe.

Suivre les variables · une compréhension, élément par élément
Ce qui s'affiche

[3]Recherche

Ce que disent les sources

  • Tutoriel officiel de Python, 5.1.3 — Une liste en compréhension est une expression entre crochets suivie d'une clause for, puis de zéro ou plusieurs clauses for ou if ; le résultat est une nouvelle liste. Les clauses gardent le même ordre que dans les boucles imbriquées équivalentes. Si l'expression est un tuple, elle doit être entre parenthèses [1].
  • Tutoriel officiel de Python, 5.4 et 5.5 — Les ensembles et les dictionnaires ont aussi leurs compréhensions : {x for x in 'abracadabra' if x not in 'abc'}, {x: x**2 for x in (2, 4, 6)} [1].
  • Tests de ce livre — Le message did you forget parentheses around the comprehension target? est le même sous Python 3.12.13 et 3.15 (journal de test).
[4]Mythes
« Une compréhension est plus rapide, donc toujours meilleure. »
elle est souvent un peu plus rapide, mais le critère est la lisibilité ; une compréhension illisible coûte plus cher que quelques microsecondes.
« Tout ce qui tient en une ligne est plus lisible. »
une ligne qui demande trois lectures est moins lisible que trois lignes qui se lisent d'un coup.
« Les compréhensions, c'est seulement pour les listes. »
il y en a pour les dictionnaires et les ensembles, avec la même logique.
[5]Quiz

1. Que vaut [x * 2 for x in range(4) if x % 2 == 0] ?

le filtre garde 0 et 2 (les pairs), puis on les double (sortie vérifiée).

2. Que vaut {len(m) for m in ["radis", "ail", "chou"]} ?

les accolades sans : donnent un ensemble des longueurs (sortie vérifiée).

3. Pour afficher chaque mesure d'une liste, que faut-il écrire ?

une compréhension construit une collection ; pour agir (afficher), une boucle.
[6]Kit

🎒 Ton kit — Épisode 1

25 min

Exercice : du TP du Pro aux compréhensions

  1. Reprends ton TP du Pro et repère quatre boucles qui construisent une liste ou un dictionnaire.
  2. Réécris chacune en compréhension, et vérifie que le résultat est identique (un assert par boucle, Pro, épisode 2).
  3. Garde en boucle celle qui devient moins lisible, et écris en commentaire pourquoi.
  4. Construis l'ensemble des mois de mesure du cahier du jardin.

Code à copier : les trois programmes du concept.

Checklist

  • Je lis une compréhension « pour chaque… si… garde… ».
  • J'ai écrit une compréhension de liste, de dictionnaire et d'ensemble.
  • Je sais quand garder la boucle.
Vigilancen'utilise jamais une compréhension pour son effet (afficher, écrire, modifier) : c'est le rôle d'une boucle.
[7]Journal

✍️ Journal de bord

Parmi les compréhensions que tu as écrites aujourd'hui, laquelle relirais-tu sans effort dans trois mois ? Et laquelle préférerais-tu relire en boucle ?

[8]Sources
  1. Python Software Foundation, Tutoriel Python — 5. Structures de données (5.1.3 « Listes en compréhension », 5.4 « Ensembles », 5.5 « Dictionnaires »), documentation de Python 3.15.2, consulté le 30/10/2026.
02
S1 · JEUDI · Pratiquer

Compréhension ou boucle ?

Nettoyer et convertir le cahier du jardin avec des compréhensions, et garder une boucle là où elle est plus claire.

⏱ 30 min de pratique · 5 min de quiz
[1]Scénario
Yanis · Jeudi, 14 h

Awa jette un œil au premier script de Yanis : quatre compréhensions imbriquées sur une seule ligne de 140 caractères, qui affichent les cases vides au passage. « Ça marche ? — Oui. — Tu peux me l'expliquer ? » Il essaie. Il n'y arrive pas.

[2]Méthode

« Une transformation, un filtre, une ligne »

  1. Décrire en français ce que tu construis : « la liste des hauteurs valides de la parcelle A ».
  2. Compréhension si tu transformes et/ou filtres une séquence pour obtenir une nouvelle collection, et que la phrase tient en une ligne.
  3. Boucle dès qu'il y a un affichage, une écriture, un compteur, plusieurs étapes, ou une compréhension dans une autre.
  4. Vérifier que le résultat est le même qu'avec la boucle, sur un cas connu.

Le cahier du jardin (mesures-jardin.csv, fourni avec le livre, données inventées) commence ainsi :

Début du fichier mesures-jardin.csv
semaine;date;parcelle;hauteur_mm 1;2026-03-30;A;8,0 1;2026-03-30;B;7,5 2;2026-04-06;A;14,5
Code
import csv
from pathlib import Path

DOSSIER = Path(__file__).parent
with open(DOSSIER / "mesures-jardin.csv", encoding="utf-8") as fichier:
    lignes = list(csv.DictReader(fichier, delimiter=";"))

valides = [ligne for ligne in lignes if ligne["hauteur_mm"] not in ("", "n.m.")]
print(len(lignes), "lignes,", len(valides), "valides")

hauteurs_a = {int(l["semaine"]): float(l["hauteur_mm"].replace(",", "."))
              for l in valides if l["parcelle"] == "A"}
print(hauteurs_a)

semaines_mesurees = {int(l["semaine"]) for l in valides if l["parcelle"] == "B"}
manquantes = set(range(1, 13)) - semaines_mesurees
print("Semaines sans mesure pour B :", sorted(manquantes))

print("Cases à vérifier :")
for ligne in lignes:
    if ligne["hauteur_mm"] in ("", "n.m."):
        print(f"  semaine {ligne['semaine']}, parcelle {ligne['parcelle']} : {ligne['hauteur_mm']!r}")

Sortie réelle :

Ce que tu vois
24 lignes, 22 valides {1: 8.0, 2: 14.5, 3: 22.0, 4: 31.5, 5: 40.0, 6: 47.5, 8: 60.5, 9: 66.0, 10: 70.5, 11: 73.0, 12: 74.5} Semaines sans mesure pour B : [8] Cases à vérifier : semaine 7, parcelle A : 'n.m.' semaine 8, parcelle B : ''

Trois compréhensions (filtrer, construire un dictionnaire, construire un ensemble), et une boucle pour l'affichage. set(range(1, 13)) - semaines_mesurees est la différence de deux ensembles : les éléments du premier qui ne sont pas dans le second. !r dans une f-string affiche la valeur avec ses guillemets : une case vide se voit (''). La lecture du CSV reprend le Pro (Pro, épisode 13).

Fichier fourni · données inventées
[3]Pourquoi ça marche

Une compréhension dit ce que l'on construit ; une boucle dit ce que l'on fait. Les séparer rend chaque ligne lisible d'un coup d'œil : quand Awa lit valides = [… if …], elle sait qu'aucune donnée n'est modifiée, aucun fichier écrit. Et la vérification sur un cas connu garantit que la réécriture n'a rien changé au résultat.

À retenirune transformation ou un filtre → une compréhension ; une action → une boucle ; une vérification après chaque réécriture.
[4]Entraînement

Bon réflexe ou piège ?

Je filtre les cases vides avec une compréhension, puis j'affiche les cases écartées avec une boucle.
chaque outil fait ce pour quoi il est fait.
J'imbrique trois compréhensions sur une ligne : ça fait pro.
personne ne pourra la relire, toi compris ; découpe en étapes nommées.
J'écris [fichier.write(l) for l in lignes] pour écrire un fichier.
c'est une action ; une boucle for est faite pour ça.
Après avoir réécrit une boucle en compréhension, je vérifie avec un assert sur le même cas.
la réécriture ne doit rien changer au résultat.
J'ignore les cases « n.m. » sans le dire.
une donnée écartée en silence fausse l'analyse ; signale-la.
[5]Quiz

1. Que vaut set(range(1, 6)) - {2, 4} ?

la différence garde les éléments du premier ensemble absents du second.

2. Dans le script, pourquoi l'affichage des cases à vérifier est-il une boucle ?

c'est la règle de la méthode : construire → compréhension ; agir → boucle.

3. Qu'affiche print(f"{'':>3}|{''!r}") ?

trois espaces pour la largeur, puis !r montre la chaîne vide avec ses guillemets.
[6]Kit

🎒 Ton kit — Épisode 2

30 min

Exercice : le cahier du jardin

  1. Télécharge mesures-jardin.csv (bouton de la méthode) et place une copie à côté de ton script.
  2. Avec des compréhensions : les lignes valides ; le dictionnaire {semaine: hauteur} de chaque parcelle ; l'ensemble des semaines manquantes.
  3. Avec une boucle : le rapport des cases à vérifier.
  4. Vérifie : 22 lignes valides, la semaine 7 manque pour A, la semaine 8 pour B.

Code à copier : le programme de la méthode.

Checklist

  • Mes compréhensions construisent, mes boucles agissent.
  • Les cases écartées sont signalées.
  • J'ai vérifié mes résultats sur les valeurs attendues.
Vigilancetravaille sur une copie du cahier du jardin, jamais sur l'original.
[7]Journal

✍️ Journal de bord

Dans ton TP du Pro, une donnée a-t-elle été écartée ou modifiée sans que tu le saches ? Comment l'aurais-tu vu ?

[8]Sources
  1. Python Software Foundation, Tutoriel Python — 5. Structures de données, 5.4 « Ensembles » (différence de deux ensembles), consulté le 30/10/2026.
  2. Programme exécuté le 30/10/2026 sous Python 3.12.13 et 3.15 sur mesures-jardin.csv (données inventées) (journal de test de ce livre).
Semaine 2 · Chercher et trier
03
S2 · MARDI · Comprendre

Chercher, trier, couper en deux

La recherche séquentielle, le tri par insertion, sorted avec une clé, et la recherche dichotomique dans une liste triée.

⏱ 30 min de lecture · 5 min de quiz
[1]Scénario
Yanis · Mardi, 16 h

Pour croiser ses mesures avec la météo, Yanis devra retrouver la température d'une date précise parmi cinq ans de relevés quotidiens : 1 826 jours. Son premier réflexe : parcourir toute la liste à chaque mesure. Awa, qui a fait NSI au lycée : « Ta liste est triée par date. Tu cherches un mot dans un dictionnaire papier en lisant toutes les pages depuis la première ? »

[2]Concept

Chercher et trier

1. La recherche séquentielle. On regarde chaque élément, dans l'ordre, et on s'arrête dès qu'on trouve.

Code
from datetime import date, timedelta


def chercher(liste, cible):
    """Renvoie la position de cible dans liste, ou None si elle n'y est pas."""
    for position in range(len(liste)):
        if liste[position] == cible:
            return position
    return None


debut = date(2021, 6, 16)
dates = [debut + timedelta(days=k) for k in range(1826)]
print(len(dates), dates[0], dates[-1])
print(chercher(dates, date(2026, 4, 20)))
print(chercher(dates, date(2030, 1, 1)))

Affiche 1826 2021-06-16 2026-06-15, puis 1769, puis None. timedelta(days=k) est une durée de k jours ; ajoutée à une date, elle donne une autre date (le module datetime, Pro, épisode 11). La liste des dates est une compréhension (épisode 1). Pour trouver le 20 avril 2026, la boucle a fait 1 770 comparaisons ; pour une date absente, 1 826.

2. Trier : le tri par insertion, à la main. Comme des cartes qu'on range une à une : on prend l'élément suivant et on le fait glisser vers la gauche jusqu'à sa place.

Code
def tri_insertion(liste):
    """Renvoie une copie triée de liste (tri par insertion)."""
    valeurs = list(liste)
    for i in range(1, len(valeurs)):
        courant = valeurs[i]
        j = i - 1
        while j >= 0 and valeurs[j] > courant:
            valeurs[j + 1] = valeurs[j]
            j = j - 1
        valeurs[j + 1] = courant
        print(f"après le tour {i} : {valeurs}")
    return valeurs


print(tri_insertion([31.5, 8.0, 22.0, 14.5, 40.0]))

Affiche :

Ce que tu vois
après le tour 1 : [8.0, 31.5, 22.0, 14.5, 40.0] après le tour 2 : [8.0, 22.0, 31.5, 14.5, 40.0] après le tour 3 : [8.0, 14.5, 22.0, 31.5, 40.0] après le tour 4 : [8.0, 14.5, 22.0, 31.5, 40.0] [8.0, 14.5, 22.0, 31.5, 40.0]

Au tour 4, 40.0 est déjà à sa place : la boucle while ne fait rien. Sur une liste presque triée, le tri par insertion travaille donc très peu.

3. Trier avec Python : sorted et la clé. Dans ton code, tu utiliseras sorted (Pro, épisode 6). Le paramètre key reçoit une fonction appliquée à chaque élément : on trie selon ce qu'elle renvoie.

Code
releves = [("2026-04-06", "A", 14.5), ("2026-04-06", "B", 12.0),
           ("2026-03-30", "A", 8.0), ("2026-03-30", "B", 7.5)]


def hauteur(releve):
    """Renvoie la hauteur d'un relevé (date, parcelle, hauteur)."""
    return releve[2]


print(sorted(releves))
print(sorted(releves, key=hauteur, reverse=True))
par_parcelle = sorted(releves, key=lambda releve: releve[1])
print(par_parcelle)

Affiche :

Ce que tu vois
[('2026-03-30', 'A', 8.0), ('2026-03-30', 'B', 7.5), ('2026-04-06', 'A', 14.5), ('2026-04-06', 'B', 12.0)] [('2026-04-06', 'A', 14.5), ('2026-04-06', 'B', 12.0), ('2026-03-30', 'A', 8.0), ('2026-03-30', 'B', 7.5)] [('2026-04-06', 'A', 14.5), ('2026-03-30', 'A', 8.0), ('2026-04-06', 'B', 12.0), ('2026-03-30', 'B', 7.5)]
  • Sans clé, les tuples se comparent élément par élément : d'abord la date (des textes au format AAAA-MM-JJ se trient comme des dates), puis la parcelle.
  • key=hauteur trie selon la hauteur ; reverse=True, de la plus grande à la plus petite.
  • lambda releve: releve[1] est une fonction anonyme d'une ligne : elle fait la même chose qu'un def qui renvoie releve[1]. Utile pour une clé courte ; dès que c'est plus long, un def avec un nom et une docstring.
  • Le tri est stable : dans la troisième ligne, les deux relevés de A gardent leur ordre de départ (le 6 avril, puis le 30 mars).

4. La recherche dichotomique. Dans une liste triée, on regarde l'élément du milieu : s'il est trop petit, la cible ne peut être qu'à droite ; trop grand, à gauche. À chaque tour, on élimine la moitié de ce qui reste.

Code
from bisect import bisect_left
from datetime import date, timedelta


def chercher_dicho(liste_triee, cible):
    """Renvoie la position de cible dans une liste TRIÉE, ou None (recherche dichotomique)."""
    gauche, droite = 0, len(liste_triee) - 1
    while gauche <= droite:
        milieu = (gauche + droite) // 2
        if liste_triee[milieu] == cible:
            return milieu
        elif liste_triee[milieu] < cible:
            gauche = milieu + 1
        else:
            droite = milieu - 1
    return None


debut = date(2021, 6, 16)
dates = [debut + timedelta(days=k) for k in range(1826)]
print(chercher_dicho(dates, date(2026, 4, 20)))
print(chercher_dicho(dates, date(2030, 1, 1)))
i = bisect_left(dates, date(2026, 4, 20))
print(i, dates[i])
print(chercher_dicho([5, 1, 4, 2, 3], 2))

Affiche 1769, None, 1769 2026-04-20, puis None.

  • L'invariant : si la cible est dans la liste, elle est entre gauche et droite (inclus). La terminaison : à chaque tour, l'écart droite - gauche diminue ; quand il devient négatif, la boucle s'arrête.
  • Le module bisect de la bibliothèque standard fait la dichotomie pour toi : bisect_left(liste, x) renvoie la position où insérer x pour garder la liste triée ; si x est présent, c'est sa position.
  • La liste doit être triée. Dans [5, 1, 4, 2, 3], le 2 est bien là, mais la dichotomie part du mauvais côté et répond None, sans aucun message d'erreur.
Simulateur de dichotomie · 32 dates triées, tous les 3 jours
[3]Recherche

Ce que disent les sources

  • Guide officiel du tri — sorted et list.sort acceptent une fonction key, appelée une fois par élément ; les tris sont garantis stables : des éléments de même clé gardent leur ordre de départ. Le guide nomme l'algorithme utilisé par Python : Timsort, qui tire parti de l'ordre déjà présent dans les données [1].
  • Documentation officielle de bisect — bisect_left renvoie le point d'insertion qui garde la liste triée, avant les éléments égaux ; la recherche coûte O(log n), mais une insertion coûte O(n) ; le paramètre key existe depuis Python 3.10 ; pour retrouver une valeur précise, un dictionnaire est plus performant [2].
  • Ressource Éduscol « Recherche dichotomique » (mars 2020) — L'algorithme s'applique à un tableau ordonné ; la largeur droite - gauche est un variant qui prouve la terminaison ; l'invariant « si la valeur est présente, elle est entre gauche et droite » prouve la correction ; pour un tableau de taille au plus 2ⁿ, il y a au plus n tours [3].
  • Programme de NSI, classe de première — La recherche dichotomique dans un tableau trié et les tris par insertion et par sélection y figurent, avec leur terminaison, leur correction et leur coût (quadratique dans le pire cas pour ces deux tris) [4].
[4]Mythes
« La dichotomie marche sur n'importe quelle liste. »
seulement sur une liste triée ; sinon, elle répond faux sans prévenir (None pour un 2 bien présent).
« Pour chercher, il faut toujours trier d'abord. »
trier coûte plus cher qu'une recherche séquentielle ; on trie si l'on va chercher souvent dans la même liste.
« lambda, c'est plus avancé qu'un def. »
c'est la même fonction sans nom, limitée à une expression ; un def nommé est souvent plus lisible.
[5]Quiz

1. Que renvoie sorted([("A", 3), ("B", 1), ("A", 1)], key=lambda r: r[1]) ?

on trie sur le nombre ; les deux 1 gardent leur ordre de départ (tri stable) : (« B », 1) puis (« A », 1) (sortie vérifiée).

2. Combien de tours fait au plus la dichotomie sur 1 826 dates ?

2¹⁰ = 1 024 < 1 826 ≤ 2 048 = 2¹¹ ; au plus 11 tours (mesuré à l'épisode 4).

3. Que se passe-t-il si l'on applique la dichotomie à une liste non triée ?

rien ne la prévient ; c'est à toi de garantir que la liste est triée.
[6]Kit

🎒 Ton kit — Épisode 3

25 min

Exercice : retrouver une date

  1. Sur papier, cherche le 23 mai 2026 dans la liste des 32 dates du simulateur, en notant gauche, droite et le milieu à chaque tour.
  2. Lance chercher_dicho sur les 1 826 dates et vérifie avec bisect_left.
  3. Trie les relevés du jardin par hauteur décroissante avec key, puis par parcelle avec une lambda.
  4. Fais tourner tri_insertion sur cinq hauteurs de ton choix et vérifie chaque tour à la main.

Code à copier : les quatre programmes du concept.

Checklist

  • Je sais expliquer la dichotomie avec ses mots : milieu, moitié gardée, arrêt.
  • J'utilise sorted avec key et reverse.
  • Je vérifie qu'une liste est triée avant d'y chercher par dichotomie.
Vigilanceune dichotomie sur une liste non triée ne plante pas : elle se trompe en silence. Trie d'abord, ou vérifie (liste == sorted(liste)).
[7]Journal

✍️ Journal de bord

Dans ta vie de tous les jours (un dictionnaire papier, une liste de classe, un rayon de bibliothèque), où utilises-tu déjà une recherche dichotomique sans le savoir ?

[8]Sources
  1. Python Software Foundation, Sorting Techniques, documentation de Python 3.15.2, consulté le 30/10/2026.
  2. Python Software Foundation, bisect — Array bisection algorithm, consulté le 30/10/2026.
  3. Ministère de l'Éducation nationale, Éduscol, Recherche dichotomique, ressource pour la spécialité NSI, mars 2020, consulté le 30/10/2026.
  4. Ministère de l'Éducation nationale, Programme de numérique et sciences informatiques de première générale, annexe (BO spécial n° 1 du 22 janvier 2019), consulté le 30/10/2026.
04
S2 · JEUDI · Pratiquer

Compter ses comparaisons

Instrumenter la recherche séquentielle et la dichotomie, mesurer sur 10, 100 et 1 826 éléments, puis conclure.

⏱ 30 min de pratique · 5 min de quiz
[1]Scénario
Yanis · Jeudi, 18 h

« La dichotomie, c'est plus rapide. » Yanis le répète, mais il ne l'a jamais vu. Sur ses dix premières dates, les deux recherches répondent instantanément. Awa : « Plus rapide de combien ? Sur combien de données ? Tu ne peux pas le dire en entretien sans l'avoir mesuré. »

[2]Méthode

« Instrumenter avant de croire »

  1. Choisir ce qu'on compte : le nombre de comparaisons (ou de tours de boucle), qui ne dépend pas de la vitesse de l'ordinateur.
  2. Ajouter un compteur dans une copie de la fonction, et le renvoyer avec le résultat (un tuple, Pro, épisode 8).
  3. Mesurer sur plusieurs tailles (10, 100, 1 826) et plusieurs cas : le meilleur, le pire, l'absent.
  4. Conclure à partir du tableau, pas d'une impression.
Code
from datetime import date, timedelta


def chercher_compte(liste, cible):
    """Recherche séquentielle ; renvoie (position ou None, nombre de comparaisons)."""
    comparaisons = 0
    for position in range(len(liste)):
        comparaisons = comparaisons + 1
        if liste[position] == cible:
            return position, comparaisons
    return None, comparaisons


def chercher_dicho_compte(liste_triee, cible):
    """Recherche dichotomique ; renvoie (position ou None, nombre de tours de boucle)."""
    gauche, droite = 0, len(liste_triee) - 1
    tours = 0
    while gauche <= droite:
        tours = tours + 1
        milieu = (gauche + droite) // 2
        if liste_triee[milieu] == cible:
            return milieu, tours
        elif liste_triee[milieu] < cible:
            gauche = milieu + 1
        else:
            droite = milieu - 1
    return None, tours


debut = date(2021, 6, 16)
for n in (10, 100, 1826):
    dates = [debut + timedelta(days=k) for k in range(n)]
    for nom, cible in (("première", dates[0]), ("dernière", dates[-1]), ("absente", date(2030, 1, 1))):
        _, seq = chercher_compte(dates, cible)
        _, dic = chercher_dicho_compte(dates, cible)
        print(f"n={n:>4}  {nom:<9} séquentielle : {seq:>4}   dichotomie : {dic:>2}")

Sortie réelle :

Ce que tu vois
n= 10 première séquentielle : 1 dichotomie : 3 n= 10 dernière séquentielle : 10 dichotomie : 4 n= 10 absente séquentielle : 10 dichotomie : 4 n= 100 première séquentielle : 1 dichotomie : 6 n= 100 dernière séquentielle : 100 dichotomie : 7 n= 100 absente séquentielle : 100 dichotomie : 7 n=1826 première séquentielle : 1 dichotomie : 10 n=1826 dernière séquentielle : 1826 dichotomie : 11 n=1826 absente séquentielle : 1826 dichotomie : 11

_ est un nom de variable conventionnel pour une valeur qu'on n'utilise pas (ici la position). Le tableau dit tout : quand n est multiplié par 10, la recherche séquentielle fait 10 fois plus de comparaisons dans le pire cas ; la dichotomie, environ 3 tours de plus. Et pour la toute première date, la recherche séquentielle gagne (1 contre 10) : la dichotomie n'est pas meilleure dans tous les cas, elle l'est dans le pire cas et sur de grandes listes.

[3]Pourquoi ça marche

Compter les comparaisons plutôt que les secondes donne un résultat qui ne dépend ni de l'ordinateur, ni des autres programmes ouverts : le même chez toi, chez Awa et chez le recruteur. Mesurer plusieurs tailles montre comment le travail grandit avec les données, ce qui compte bien plus que le temps sur un seul exemple. Tu viens de mesurer ce que l'épisode 7 appellera la complexité.

À retenirun compteur, plusieurs tailles, le meilleur et le pire cas ; on conclut sur la croissance, pas sur un seul chiffre.
[4]Entraînement

Bon réflexe ou piège ?

Je teste ma recherche seulement sur 10 éléments : elle répond tout de suite, donc elle est rapide.
sur 10 éléments, tout est rapide ; c'est la croissance avec n qui compte.
Je compte les comparaisons plutôt que les secondes.
le résultat ne dépend pas de la machine.
Je mesure le meilleur cas, le pire cas et l'élément absent.
la première date avantage la recherche séquentielle, la dernière la pénalise.
Je laisse le compteur dans la fonction de mon projet.
l'instrumentation se fait sur une copie ; la fonction du projet reste simple.
Je trie ma liste à chaque recherche pour pouvoir utiliser la dichotomie.
trier coûte plus cher qu'une recherche séquentielle ; on trie une fois, on cherche souvent.
[5]Quiz

1. D'après le tableau, combien de comparaisons fait la recherche séquentielle pour une date absente parmi 1 826 ?

elle doit tout parcourir avant de conclure à l'absence (sortie réelle).

2. Pourquoi la recherche séquentielle gagne-t-elle pour la première date ?

1 comparaison contre 10 tours : le meilleur cas de l'une est un cas moyen pour l'autre.

3. Quand n passe de 100 à 1 826, le pire cas de la dichotomie passe…

il ne gagne que quelques tours quand la liste est multipliée par 18 (sortie réelle).
[6]Kit

🎒 Ton kit — Épisode 4

30 min

Exercice : le tableau des comparaisons

  1. Lance le programme et recopie le tableau dans ton journal.
  2. Ajoute la taille 10 000 et prédis les nombres avant de lancer.
  3. Ajoute un cas « au milieu » (dates[n // 2]) : que fait la dichotomie ?
  4. Trace les deux pires cas en fonction de n avec matplotlib (Pro, épisode 17).

Code à copier : le programme de la méthode.

Checklist

  • J'ai mesuré trois tailles et trois cas.
  • J'ai prédit avant de lancer.
  • Je sais dire dans quels cas la dichotomie gagne.
Vigilanceun compteur ajouté pour mesurer se retire de la version du projet ; garde la version instrumentée dans un fichier à part.
[7]Journal

✍️ Journal de bord

Ta prédiction pour 10 000 éléments était-elle juste ? Qu'est-ce qui t'a surpris dans le tableau ?

[8]Sources
  1. Programme exécuté le 30/10/2026 sous Python 3.12.13 et 3.15 (journal de test de ce livre).
  2. Ministère de l'Éducation nationale, Éduscol, Recherche dichotomique (au plus n tours pour un tableau de taille au plus 2ⁿ), mars 2020, consulté le 30/10/2026.
Semaine 3 · La récursivité
05
S3 · MARDI · Comprendre

Une fonction qui s'appelle elle-même

Écrire une fonction récursive avec son cas de base, suivre la pile d'appels, connaître la limite de Python.

⏱ 30 min de lecture · 5 min de quiz
[1]Scénario
Yanis · Mardi, 17 h

Le jardin partagé a rangé ses fichiers à sa façon : un dossier par année, dedans un dossier par parcelle, parfois un sous-dossier « photos », parfois un fichier égaré à la racine. Combien y a-t-il de fichiers de mesures ? Yanis empile trois boucles for, une par niveau… et découvre qu'en 2026, quelqu'un a ajouté un quatrième niveau.

[2]Concept

La récursivité

Une fonction récursive est une fonction qui s'appelle elle-même, sur un problème plus petit. Elle a toujours deux parties :

  • le cas de base : un problème si petit qu'on connaît la réponse directement ;
  • le cas récursif : on réduit le problème, on s'appelle sur la partie réduite, et on combine.

La somme d'une liste. La somme de [3, 5, 2], c'est 3 + la somme de [5, 2]. Et la somme d'une liste vide, c'est 0.

Code
def somme(liste):
    """Renvoie la somme des éléments de liste (version récursive)."""
    if len(liste) == 0:
        return 0
    return liste[0] + somme(liste[1:])


def maximum(liste):
    """Renvoie le plus grand élément d'une liste non vide (version récursive)."""
    if len(liste) == 1:
        return liste[0]
    reste = maximum(liste[1:])
    if liste[0] > reste:
        return liste[0]
    return reste


print(somme([3, 5, 2]))
print(somme([]))
print(maximum([31.5, 8.0, 74.5, 22.0]))

Affiche 10, 0, puis 74.5. liste[1:] est une tranche : tout sauf le premier élément (Pro, épisode 3).

La pile d'appels. Chaque appel a ses propres variables locales (Pro, épisode 1) et attend le résultat de l'appel suivant. Une version bavarde le montre :

Code
def somme(liste, profondeur=0):
    """Version bavarde de somme : affiche chaque appel et chaque retour."""
    marge = "    " * profondeur
    print(f"{marge}somme({liste})")
    if len(liste) == 0:
        print(f"{marge}→ renvoie 0 (cas de base)")
        return 0
    resultat = liste[0] + somme(liste[1:], profondeur + 1)
    print(f"{marge}→ renvoie {resultat}")
    return resultat


somme([3, 5, 2])

Affiche :

Ce que tu vois
somme([3, 5, 2]) somme([5, 2]) somme([2]) somme([]) → renvoie 0 (cas de base) → renvoie 2 → renvoie 7 → renvoie 10

Les appels s'empilent (vers la droite), jusqu'au cas de base ; puis ils se dépilent, chacun ajoutant son élément au résultat reçu : 0, puis 2, puis 7, puis 10. Le dernier appel commencé est le premier terminé.

La dichotomie, en récursif. Chercher dans une moitié, c'est le même problème en plus petit (épisode 3) :

Code
def chercher_rec(liste_triee, cible, gauche, droite):
    """Recherche dichotomique récursive entre les positions gauche et droite (incluses)."""
    if gauche > droite:
        return None
    milieu = (gauche + droite) // 2
    if liste_triee[milieu] == cible:
        return milieu
    if liste_triee[milieu] < cible:
        return chercher_rec(liste_triee, cible, milieu + 1, droite)
    return chercher_rec(liste_triee, cible, gauche, milieu - 1)


semaines = [1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12]
print(chercher_rec(semaines, 9, 0, len(semaines) - 1))
print(chercher_rec(semaines, 7, 0, len(semaines) - 1))

Affiche 7 (la semaine 9 est à la position 7), puis None (la semaine 7 manque). Deux cas de base : la zone est vide (gauche > droite), ou la cible est au milieu.

La limite. Python limite le nombre d'appels empilés :

Code
import sys


def somme(liste):
    """Renvoie la somme des éléments de liste (version récursive)."""
    if len(liste) == 0:
        return 0
    return liste[0] + somme(liste[1:])


print(sys.getrecursionlimit())
print(somme(list(range(900))))
print(somme(list(range(2000))))

Affiche 1000, puis 404550, puis s'arrête :

Message d'erreur
Traceback (most recent call last): File "e5_limite.py", line 13, in <module> print(somme(list(range(2000)))) File "e5_limite.py", line 8, in somme return liste[0] + somme(liste[1:]) File "e5_limite.py", line 8, in somme return liste[0] + somme(liste[1:]) File "e5_limite.py", line 8, in somme return liste[0] + somme(liste[1:]) [Previous line repeated 996 more times] RecursionError: maximum recursion depth exceeded

RecursionError : « profondeur maximale de récursion dépassée ». La limite par défaut vaut 1 000 appels (sortie réelle sous Python 3.12 et 3.15). Python la lit en 3 lignes, puis résume : « la ligne précédente s'est répétée 996 fois de plus ».

L'oubli du cas de base donne la même erreur :

Code
def compte_a_rebours(n):
    print(n)
    compte_a_rebours(n - 1)


compte_a_rebours(3)

Affiche 3, 2, 1, 0, -1… et ainsi de suite près de mille fois, puis RecursionError. Il manquait if n == 0: return.

Quand préférer une boucle ? Pour parcourir une liste, la boucle est plus simple et n'a pas de limite. La récursivité brille quand le problème est imbriqué : des dossiers dans des dossiers, des données dans des données. C'est le cas du jardin (épisode 6).

Pile d'appels · somme([3, 5, 2])

[3]Recherche

Ce que disent les sources

  • Programme de NSI, classe de terminale — La récursivité y est présentée comme « une méthode fondamentale de programmation » ; capacités attendues : écrire un programme récursif et analyser son fonctionnement. La méthode « diviser pour régner » (dont le tri fusion) l'exploite [1].
  • Tests de ce livre — sys.getrecursionlimit() renvoie 1000 sous Python 3.12.13, 3.15.0 et 3.15.2 ; la somme récursive de 900 éléments réussit, celle de 2 000 lève RecursionError: maximum recursion depth exceeded (journal de test).
  • Fiche de révision NSI (L'Étudiant, mai 2025) — Une fonction récursive comporte une partie terminale qui définit les conditions d'arrêt ; les appels successifs s'empilent dans la pile d'exécution, qui fonctionne en « dernier entré, premier sorti » [2].
[4]Mythes
« La récursivité est réservée aux mathématiciens. »
tu l'utilises dès que tu parcours des dossiers imbriqués ou que tu coupes un problème en deux.
« Une fonction récursive ne s'arrête jamais. »
elle s'arrête au cas de base ; sans cas de base, Python l'arrête à la limite de 1 000 appels.
« Récursif, c'est toujours plus élégant, donc meilleur. »
pour une liste, une boucle est plus simple et sans limite ; la récursivité sert les problèmes imbriqués.
[5]Quiz

1. Que renvoie somme([4]) avec la fonction récursive du concept ?

4 + somme([]), et somme([]) vaut 0 (cas de base).

2. Dans la pile d'appels de somme([3, 5, 2]), quel appel se termine en premier ?

le dernier appel commencé (le cas de base) est le premier terminé (sortie réelle).

3. Que provoque l'oubli du cas de base ?

les appels s'empilent jusqu'à la limite de Python (sortie réelle).
[6]Kit

🎒 Ton kit — Épisode 5

25 min

Exercice : penser récursif

  1. Écris compter(liste, valeur) en récursif : combien de fois valeur apparaît dans liste. Cas de base ? Cas récursif ?
  2. Ajoute une version bavarde (avec profondeur) et prédis sa sortie sur [2, 7, 2] avant de la lancer.
  3. Vérifie tes fonctions avec des assert, dont la liste vide.
  4. Corrige compte_a_rebours pour qu'elle s'arrête à 0.

Code à copier : les programmes somme, version bavarde, et chercher_rec.

Checklist

  • Chacune de mes fonctions récursives a un cas de base.
  • Je sais dessiner la pile d'appels d'un petit exemple.
  • Je connais la limite de 1 000 appels et ce qu'elle provoque.
Vigilanceavant de lancer une fonction récursive, vérifie que chaque appel rapproche du cas de base ; sinon, c'est la RecursionError assurée.
[7]Journal

✍️ Journal de bord

Explique avec tes mots, sans code, comment tu compterais les fichiers de dossiers emboîtés comme des poupées russes.

[8]Sources
  1. Ministère de l'Éducation nationale, Programme de numérique et sciences informatiques de terminale générale, annexe, consulté le 30/10/2026.
  2. SchoolMouv pour L'Étudiant, Spécialité NSI en terminale : l'algorithme récursif, 14 mai 2025, consulté le 30/10/2026.
  3. Programmes exécutés le 30/10/2026 sous Python 3.12.13, 3.15.0 et 3.15.2 (journaux de test de ce livre).
06
S3 · JEUDI · Pratiquer

Parcourir des dossiers imbriqués

Compter récursivement les fichiers de mesures d'une arborescence, puis comparer avec Path.rglob.

⏱ 30 min de pratique · 5 min de quiz
[1]Scénario
Yanis · Jeudi, 16 h

La doctorante qui gère le cahier du jardin lui envoie l'archive : jardin-exemple, avec des années, des parcelles, un dossier photos et un LISEZMOI.txt. « Combien de fichiers de mesures ? Je n'en ai aucune idée. » Cette fois, Yanis n'empile pas de boucles.

[2]Méthode

« D'abord le cas de base, puis un niveau, puis la confiance »

  1. Le cas de base : un fichier. S'il finit par .csv, il compte pour 1 ; sinon, 0.
  2. Le cas récursif : un dossier. On additionne ce que donne la fonction sur chacun de ses éléments.
  3. Tester sur un seul niveau (un dossier qui ne contient que des fichiers), puis sur toute l'arborescence.
  4. Faire confiance à l'appel récursif : si la fonction marche pour un dossier plus petit, elle marche pour le grand.

Créer l'arborescence d'exemple (sur ton ordinateur, dans un dossier d'entraînement) :

Code
"""Crée l'arborescence d'exemple du jardin dans le dossier jardin-exemple (à côté de ce script)."""
from pathlib import Path

RACINE = Path(__file__).parent / "jardin-exemple"
for annee in ("2025", "2026"):
    for parcelle in ("A", "B"):
        dossier = RACINE / annee / parcelle
        dossier.mkdir(parents=True, exist_ok=True)
        for mois in ("04", "05", "06"):
            (dossier / f"{annee}-{mois}.csv").write_text("semaine;hauteur_mm\n", encoding="utf-8")
(RACINE / "2026" / "A" / "photos").mkdir(exist_ok=True)
(RACINE / "2026" / "A" / "photos" / "radis.jpg").write_text("", encoding="utf-8")
(RACINE / "LISEZMOI.txt").write_text("Cahier du jardin partagé\n", encoding="utf-8")
print("Arborescence créée :", RACINE.name)

Affiche Arborescence créée : jardin-exemple. mkdir(parents=True, exist_ok=True) crée aussi les dossiers parents manquants, sans erreur s'ils existent déjà ; write_text crée un petit fichier.

Compter les fichiers .csv :

Code
from pathlib import Path


def compter_csv(dossier):
    """Renvoie le nombre de fichiers .csv dans dossier et tous ses sous-dossiers."""
    total = 0
    for chemin in dossier.iterdir():
        if chemin.is_dir():
            total = total + compter_csv(chemin)
        elif chemin.suffix == ".csv":
            total = total + 1
    return total


RACINE = Path(__file__).parent / "jardin-exemple"
print(compter_csv(RACINE / "2026" / "B"))
print(compter_csv(RACINE))
print(len(list(RACINE.rglob("*.csv"))))

Sortie réelle :

Ce que tu vois
3 12 12
  • is_dir() dit si un chemin est un dossier ; suffix donne l'extension (".csv", ".jpg") ; iterdir() parcourt le contenu d'un dossier (Pro, épisode 17).
  • Le premier appel teste un seul niveau (2026/B : 3 fichiers) ; le second, toute l'arborescence : 2 années × 2 parcelles × 3 mois = 12. La photo et le LISEZMOI.txt ne comptent pas.
  • Ici, le cas de base est caché dans la boucle : un dossier vide, ou qui ne contient que des fichiers, ne provoque aucun appel récursif.
  • rglob("*.csv") (recursive glob) fait le même parcours pour toi : il renvoie tous les chemins qui correspondent au motif, dans tous les sous-dossiers. Dans ton projet, tu l'utiliseras ; écrire compter_csv t'apprend ce qu'il fait.
Remets les lignes dans l'ordre · clique les lignes une par une
Ton programme
Lignes à placer
[3]Pourquoi ça marche

La récursivité épouse la forme du problème : un dossier contient des fichiers et des dossiers, qui contiennent des fichiers et des dossiers… Peu importe le nombre de niveaux, la même fonction s'applique à chaque niveau. Tester d'abord un seul niveau vérifie le cas de base et l'addition ; la confiance dans l'appel récursif fait le reste. Comparer avec rglob, un outil de la bibliothèque standard, donne un cas connu pour vérifier.

À retenircas de base, un niveau, puis tout ; on vérifie avec l'outil standard.
[4]Entraînement

Bon réflexe ou piège ?

J'écris une boucle par niveau de dossiers.
au premier niveau ajouté, ton code est faux ; la récursivité s'adapte à toutes les profondeurs.
Je teste d'abord ma fonction sur un dossier sans sous-dossier.
tu vérifies le cas le plus simple avant de faire confiance aux appels récursifs.
Je crée l'arborescence d'exemple dans mon dossier Documents, à côté de mes vrais fichiers.
on s'entraîne dans un dossier d'entraînement, sur des copies.
Je compare mon résultat avec rglob.
un outil standard donne un cas connu.
Dans mon projet, je garde ma fonction récursive plutôt que rglob, par fierté.
l'outil standard est testé et lisible ; ta fonction t'a appris le principe.
[5]Quiz

1. Que renvoie compter_csv sur un dossier vide ?

la boucle ne fait aucun tour ; total reste à 0.

2. Pourquoi la photo radis.jpg n'est-elle pas comptée ?

le dossier photos est bien parcouru, mais seuls les .csv comptent.

3. Que fait RACINE.rglob("*.csv") ?

rglob est récursif (12, comme compter_csv, sortie réelle).
[6]Kit

🎒 Ton kit — Épisode 6

30 min

Exercice : l'arborescence du jardin

  1. Dans un dossier d'entraînement, lance le script qui crée jardin-exemple.
  2. Écris compter_csv et teste-la sur 2026/B, puis sur toute l'arborescence.
  3. Ajoute un niveau (par exemple 2026/A/brouillons/2026-07.csv) et vérifie que ta fonction le trouve sans modification.
  4. Écris lister_csv(dossier) qui renvoie la liste des chemins, et compare-la avec sorted(RACINE.rglob("*.csv")).

Code à copier : les deux programmes de la méthode.

Checklist

  • Ma fonction marche sur un seul niveau, puis sur toute l'arborescence.
  • Elle trouve un niveau ajouté sans modification.
  • Mon résultat est égal à celui de rglob.
Vigilanceune fonction qui parcourt des dossiers ne doit jamais supprimer ni modifier de fichier tant qu'elle n'a pas été testée sur une copie.
[7]Journal

✍️ Journal de bord

À quel moment as-tu dû « faire confiance » à l'appel récursif sans le suivre jusqu'au bout ? Qu'est-ce qui t'a permis de le faire ?

[8]Sources
  1. Python Software Foundation, pathlib — Object-oriented filesystem paths (iterdir, is_dir, suffix, mkdir, rglob), consulté le 30/10/2026.
  2. Programmes exécutés le 30/10/2026 sous Python 3.12.13 et 3.15 dans un dossier jetable (journal de test de ce livre).
Semaine 4 · Le coût d'un algorithme
07
S4 · MARDI · Comprendre

Combien ça coûte ?

Compter les opérations selon la taille des données, reconnaître O(1), O(log n), O(n), O(n log n) et O(n²), et vérifier par la mesure.

⏱ 30 min de lecture · 5 min de quiz
[1]Scénario
Yanis · Mardi, 11 h

Les relevés météo contiennent des doublons : certaines dates apparaissent deux fois, à cause d'un export raté. Le programme de Yanis compare chaque date à toutes les suivantes. Sur 5 ans de relevés quotidiens, une seconde. Le doctorant lui propose de le lancer sur une station qui relève la température toutes les heures depuis vingt ans. Yanis part prendre un café. Quand il revient, le programme n'a pas fini.

[2]Concept

La complexité

La complexité d'un algorithme décrit comment le nombre d'opérations grandit quand la taille n des données grandit. On la note avec un O (« grand O ») : on garde l'ordre de grandeur et on oublie les détails.

Deux façons de trouver les doublons.

Code
def doublons_liste(valeurs):
    """Renvoie (doublons, comparaisons) en comparant chaque valeur à toutes les suivantes."""
    trouves = set()
    comparaisons = 0
    for i in range(len(valeurs)):
        for j in range(i + 1, len(valeurs)):
            comparaisons = comparaisons + 1
            if valeurs[i] == valeurs[j]:
                trouves.add(valeurs[i])
    return trouves, comparaisons


def doublons_ensemble(valeurs):
    """Renvoie (doublons, tests) avec un ensemble des valeurs déjà vues."""
    vues = set()
    trouves = set()
    tests = 0
    for valeur in valeurs:
        tests = tests + 1
        if valeur in vues:
            trouves.add(valeur)
        vues.add(valeur)
    return trouves, tests


releves = ["2026-04-06", "2026-04-13", "2026-04-06", "2026-04-20", "2026-04-13"]
trouves, comparaisons = doublons_liste(releves)
print(sorted(trouves), comparaisons)
trouves, tests = doublons_ensemble(releves)
print(sorted(trouves), tests)
for n in (1_000, 2_000, 4_000):
    valeurs = list(range(n))
    print(f"n={n:>5}  double boucle : {doublons_liste(valeurs)[1]:>9} comparaisons   ensemble : {doublons_ensemble(valeurs)[1]:>5} tests")

Sortie réelle :

Ce que tu vois
['2026-04-06', '2026-04-13'] 10 ['2026-04-06', '2026-04-13'] 5 n= 1000 double boucle : 499500 comparaisons ensemble : 1000 tests n= 2000 double boucle : 1999000 comparaisons ensemble : 2000 tests n= 4000 double boucle : 7998000 comparaisons ensemble : 4000 tests

Les deux trouvent les mêmes doublons. Mais quand n double, la double boucle fait 4 fois plus de comparaisons : n(n − 1)/2, de l'ordre de n², noté O(n²) ; la version avec ensemble fait 2 fois plus de tests : O(n). Pourquoi l'ensemble est-il si efficace ? Parce que valeur in vues sur un ensemble coûte en moyenne O(1) : un temps qui ne dépend pas de sa taille ; sur une liste, in parcourt tout : O(n) (Pro, épisode 8). 1_000 s'écrit aussi 1000 : le _ sert seulement à lire les grands nombres.

Les ordres de grandeur à connaître.

Code
import math

print(f"{'n':>7} {'log2 n':>7} {'n':>9} {'n log2 n':>12} {'n²':>16}")
for n in (10, 100, 1_000, 1_826, 100_000):
    print(f"{n:>7} {math.log2(n):>7.1f} {n:>9} {n * math.log2(n):>12.0f} {n * n:>16}")

Affiche :

Ce que tu vois
n log2 n n n log2 n n² 10 3.3 10 33 100 100 6.6 100 664 10000 1000 10.0 1000 9966 1000000 1826 10.8 1826 19784 3334276 100000 16.6 100000 1660964 10000000000
NotationNomExemple de ce livre
O(1)constantx in ensemble, dictionnaire[cle] (en moyenne)
O(log n)logarithmiquerecherche dichotomique (épisodes 3 et 4)
O(n)linéairerecherche séquentielle, x in liste, doublons avec un ensemble
O(n log n)quasi linéairesorted
O(n²)quadratiquedoublons par double boucle, tri par insertion dans le pire cas

Pour 100 000 éléments, un algorithme en n² fait dix milliards d'opérations ; en n log n, moins de deux millions.

Mesurer, pour vérifier. Le temps dépend de la machine, mais sa croissance suit la complexité. time.perf_counter() donne un instant précis ; la différence entre deux instants, une durée :

Code
import time


def doublons_liste(valeurs):
    """Renvoie l'ensemble des valeurs présentes plusieurs fois (double boucle)."""
    trouves = set()
    for i in range(len(valeurs)):
        for j in range(i + 1, len(valeurs)):
            if valeurs[i] == valeurs[j]:
                trouves.add(valeurs[i])
    return trouves


def doublons_ensemble(valeurs):
    """Renvoie l'ensemble des valeurs présentes plusieurs fois (ensemble des valeurs vues)."""
    vues, trouves = set(), set()
    for valeur in valeurs:
        if valeur in vues:
            trouves.add(valeur)
        vues.add(valeur)
    return trouves


for n in (1_000, 2_000, 4_000):
    valeurs = list(range(n))
    debut = time.perf_counter()
    doublons_liste(valeurs)
    t_liste = time.perf_counter() - debut
    debut = time.perf_counter()
    doublons_ensemble(valeurs)
    t_ens = time.perf_counter() - debut
    print(f"n={n:>5}  double boucle : {t_liste:.3f} s   ensemble : {t_ens:.5f} s")

Une sortie réelle (sur l'ordinateur de test, sous Python 3.15) :

Ce que tu vois
n= 1000 double boucle : 0.011 s ensemble : 0.00007 s n= 2000 double boucle : 0.058 s ensemble : 0.00020 s n= 4000 double boucle : 0.244 s ensemble : 0.00025 s

Chez toi, les chiffres seront différents ; mais la double boucle prendra toujours environ 4 fois plus de temps quand n double. Pour comparer deux petits morceaux de code, le module timeit répète la mesure et garde le meilleur temps : min(timeit.repeat("99_999 in valeurs_liste", globals=globals(), number=1_000, repeat=5)). Sur l'ordinateur de test, tester 99_999 in … 1 000 fois a pris environ 0,5 à 0,7 s avec une liste de 100 000 éléments, et quelques cent-millièmes de seconde avec un ensemble.

Course des coûts · nombre d'opérations selon la taille n

Hauteurs des barres sur une échelle logarithmique : chaque graduation multiplie par 10.

[3]Recherche

Ce que disent les sources

  • Documentation officielle, Time complexity of operations on built-in types — x in list coûte O(n) ; x in set et la lecture d'un dictionnaire coûtent O(1) en moyenne et O(n) dans le pire cas (collisions) ; list.sort() coûte O(n log n), et O(n) sur une liste déjà triée. Ces coûts sont ceux de CPython, l'interpréteur de référence [1].
  • Documentation officielle de timeit — Le module mesure le temps d'exécution de petits morceaux de code ; son horloge par défaut est time.perf_counter() ; pour des mesures répétées, le minimum est probablement le seul nombre intéressant, les valeurs plus hautes venant d'autres programmes qui interfèrent [2].
  • Programme de NSI, classe de première — Le parcours séquentiel d'un tableau a un coût linéaire ; les tris par insertion et par sélection ont un coût quadratique dans le pire cas ; l'étude des coûts prend tout son sens pour de nombreuses données [3].
[4]Mythes
« Un ordinateur plus rapide règle le problème. »
un ordinateur deux fois plus rapide divise le temps par 2 ; passer de n² à n le divise par des milliers quand n est grand.
« La complexité, c'est le temps en secondes. »
c'est la façon dont le travail grandit avec n ; les secondes dépendent de la machine.
« Il faut toujours choisir l'algorithme de plus faible complexité. »
sur 10 éléments, la différence est invisible ; on choisit la version la plus lisible tant que les données restent petites.
[5]Quiz

1. Une fonction compare chaque élément à tous les autres. Si n est multiplié par 10, le nombre de comparaisons est multiplié par environ…

O(n²) : (10n)² = 100n².

2. Quel test est en O(1) en moyenne ?

l'ensemble trouve un élément sans parcourir ; la liste parcourt tout ; sorted est en O(n log n).

3. Pourquoi timeit garde-t-il le minimum de plusieurs mesures ?

c'est ce que recommande sa documentation.
[6]Kit

🎒 Ton kit — Épisode 7

30 min

Exercice : doublons, liste contre ensemble

  1. Lance le programme des doublons avec compteurs et vérifie le rapport ×4 et ×2.
  2. Mesure les temps chez toi pour n = 1 000, 2 000, 4 000 ; note-les dans ton journal.
  3. Prédis le temps de la double boucle pour n = 100 000 (sans le lancer !), puis lance seulement la version ensemble.
  4. Trace le nombre d'opérations des deux versions en fonction de n (Pro, épisode 17).

Code à copier : le programme des doublons et celui des mesures.

Checklist

  • Je sais reconnaître O(1), O(log n), O(n), O(n log n) et O(n²) dans un code simple.
  • J'ai vérifié une complexité par une mesure.
  • Je sais pourquoi in est rapide sur un ensemble et lent sur une liste.
Vigilancene lance pas un algorithme en n² sur des centaines de milliers d'éléments « pour voir » : estime d'abord.
[7]Journal

✍️ Journal de bord

Quelle est ta prédiction pour la double boucle avec 100 000 éléments ? Comment l'as-tu calculée ?

[8]Sources
  1. Python Software Foundation, Time complexity of operations on built-in types, documentation de Python 3.15.2, consulté le 30/10/2026.
  2. Python Software Foundation, timeit — Measure execution time of small code snippets, documentation de Python 3.15, consulté le 30/10/2026.
  3. Ministère de l'Éducation nationale, Programme de numérique et sciences informatiques de première générale, annexe (BO spécial n° 1 du 22 janvier 2019), consulté le 30/10/2026.
  4. Programmes exécutés le 30/10/2026 sous Python 3.12.13 et 3.15 (comptages identiques ; temps propres à l'ordinateur de test) (journal de test de ce livre).
08
S4 · JEUDI · Pratiquer · Bilan d'arc

Penser algorithme : le bilan

Ton outil de synthèse, le récapitulatif de l'arc et ton badge.

⏱ 30 min · arbre de décision · carrousel · quiz final
[1]Scénario
Yanis · Jeudi, 17 h

Yanis montre à Awa son premier module pour « Pousse » : il convertit le cahier du jardin, retrouve une date météo par dichotomie, compte les fichiers d'une arborescence et repère les doublons. Le croisement mesures-météo, qui comparait chaque mesure à toutes les dates, se traînait ; il répond maintenant aussitôt. « Et tu sais dire pourquoi ? — Oui. Avant, c'était n fois n. Maintenant, n fois log n. »

[2]Outil

« Quel algorithme, quelle structure ? »

Aide à la décision interactive
[3]Carrousel

🎨 Carrousel récapitulatif — Arc 1

[4]Quiz final

1. Quelle écriture construit le dictionnaire {1: 8.0, 2: 14.5} à partir de releves = [(1, 8.0), (2, 14.5)] ?

accolades avec cle: valeur → dictionnaire ; la troisième donne un ensemble de tuples.

2. Tu dois vérifier, pour chacune des 20 000 mesures, si sa date existe dans les relevés météo. Quel choix ?

20 000 tests en O(1) au lieu de 20 000 parcours en O(n).

3. Que manque-t-il à def f(n): return n + f(n - 1) ?

sans cas de base, les appels s'empilent jusqu'à la RecursionError.

4. Sur une liste triée de 1 000 000 de dates, combien de tours fait au plus la dichotomie ?

2²⁰ = 1 048 576 ≥ 1 000 000 : au plus 20 tours.

5. Le temps d'une fonction passe de 0,05 s à 0,2 s quand les données doublent. Sa complexité est sans doute…

×4 quand n double : c'est le signe d'une croissance quadratique.
[5]Kit

🎒 Ton kit de fin d'arc

30 min

Le module algos.py

Synthèse : les quatre algorithmes de l'arc, documentés, vérifiés sur des cas connus et mesurés.

Code à copier — algos.py, dans le dossier où tu as créé jardin-exemple (épisode 6)

Code à copier
"""algos.py — les algorithmes de l'arc 1 appliqués aux données du jardin."""

import time
from datetime import date, timedelta
from pathlib import Path


def convertir_tailles(textes):
    """Convertit des hauteurs écrites avec une virgule ; ignore les cases vides et « n.m. »."""
    return [float(t.replace(",", ".")) for t in textes if t not in ("", "n.m.")]


def chercher_date(dates_triees, cible):
    """Renvoie la position de cible dans une liste de dates TRIÉE, ou None (dichotomie)."""
    gauche, droite = 0, len(dates_triees) - 1
    while gauche <= droite:
        milieu = (gauche + droite) // 2
        if dates_triees[milieu] == cible:
            return milieu
        elif dates_triees[milieu] < cible:
            gauche = milieu + 1
        else:
            droite = milieu - 1
    return None


def compter_csv(dossier):
    """Renvoie le nombre de fichiers .csv dans dossier et tous ses sous-dossiers (récursif)."""
    total = 0
    for chemin in dossier.iterdir():
        if chemin.is_dir():
            total = total + compter_csv(chemin)
        elif chemin.suffix == ".csv":
            total = total + 1
    return total


def doublons(valeurs):
    """Renvoie l'ensemble des valeurs présentes plusieurs fois (coût linéaire)."""
    vues, trouves = set(), set()
    for valeur in valeurs:
        if valeur in vues:
            trouves.add(valeur)
        vues.add(valeur)
    return trouves


# Vérifications sur des cas connus
assert convertir_tailles(["8,0", "n.m.", "", "14,5"]) == [8.0, 14.5]
dates = [date(2021, 6, 16) + timedelta(days=k) for k in range(1826)]
assert chercher_date(dates, date(2026, 4, 20)) == 1769
assert chercher_date(dates, date(2030, 1, 1)) is None
assert doublons(["2026-04-06", "2026-04-13", "2026-04-06"]) == {"2026-04-06"}
racine = Path(__file__).parent / "jardin-exemple"
assert compter_csv(racine) == 12
print("Vérifications : OK")

# Mesure : 1 000 recherches de dates
cibles = [dates[k] for k in range(0, 1826, 2)][:1000]
debut = time.perf_counter()
for cible in cibles:
    chercher_date(dates, cible)
print(f"1 000 recherches par dichotomie : {time.perf_counter() - debut:.4f} s")
debut = time.perf_counter()
for cible in cibles:
    dates.index(cible)
print(f"1 000 recherches séquentielles (index) : {time.perf_counter() - debut:.4f} s")

Une sortie réelle (ordinateur de test, Python 3.15) :

Ce que tu vois
Vérifications : OK 1 000 recherches par dichotomie : 0.0009 s 1 000 recherches séquentielles (index) : 0.0077 s

liste.index(x) est la recherche séquentielle de Python, écrite en C : très rapide pour chaque comparaison. Pourtant, avec des données qui grandissent, c'est la dichotomie, écrite en Python, qui gagne : un bon algorithme bat un code plus rapide. Les deux temps varient d'un ordinateur à l'autre ; mais quand la liste grandit, l'écart se creuse : la dichotomie fait au plus 11 tours sur 1 826 dates, index jusqu'à 1 826 comparaisons.

Consigne : garde algos.py dans le dossier de ton projet « Pousse » et fais un premier commit (Pro, épisode 18). Ajoute dans ton journal le tableau de l'épisode 4 et ta prédiction de l'épisode 7. Puis exporte ton carnet de bord.

Checklist

  • J'ai exporté mon carnet (bouton dans le menu).
  • algos.py passe ses vérifications.
  • Je sais expliquer la complexité de chacune des quatre fonctions.
[6]Journal

✍️ Journal de bord — bilan

Si le recruteur te demandait « pourquoi une dichotomie plutôt qu'une boucle ? », que répondrais-tu en trois phrases ?

✦ ALGORITHMICIENNE · ALGORITHMICIEN · ARC 1 ✦

Bravo ! Tu as terminé l'arc 1. Tu sais écrire des compréhensions, chercher et trier efficacement, penser en récursif et estimer le coût d'un algorithme avant de le lancer.

ARC 2 · SEMAINES 5 À 8

Du code robuste

Le cœur de « Pousse » prend forme : une lecture qui ne plante plus sur une case mal remplie, des données qui deviennent des objets, des tests qui tournent tout seuls, et des annotations qu'un outil vérifie avant même de lancer le code.

Semaine 5 · Les exceptions
09
S5 · MARDI · Comprendre

Quand ça tourne mal

Intercepter une exception avec try / except, utiliser else et finally, lever une exception claire avec raise, et ne jamais avaler une erreur en silence.

⏱ 30 min de lecture · 5 min de quiz
[1]Scénario
Yanis · Mardi, 9 h

Yanis lance la conversion du cahier du jardin sur toutes les lignes, sans le filtre de l'épisode 2. Le script affiche deux hauteurs, puis s'arrête net sur la case « n.m. » : ValueError, exactement comme son calculateur du Boost devant « 12,5 » (Boost, épisode 5). Au Boost, il avait lu l'erreur et changé la question posée. Cette fois, il ne peut pas réécrire le cahier : il doit apprendre à son programme à encaisser.

[2]Concept

Les exceptions

Une exception est un signal que Python lève quand une instruction ne peut pas s'exécuter : ValueError, KeyError, FileNotFoundError… Si personne ne l'intercepte, le programme s'arrête et affiche la trace (que tu sais lire, Boost, épisode 2).

Sans protection :

Code
textes = ["8,0", "14,5", "n.m.", "31,5"]
for texte in textes:
    print(float(texte.replace(",", ".")))
Ce que tu vois
8.0 14.5 Traceback (most recent call last): File "e9_sans_try.py", line 3, in <module> print(float(texte.replace(",", "."))) ValueError: could not convert string to float: 'n.m.'

try, except, else, finally.

Code
def essayer(texte):
    """Montre quels blocs s'exécutent selon la hauteur lue."""
    try:
        hauteur = float(texte.replace(",", "."))
    except ValueError:
        print(f"{texte!r} : illisible")
    else:
        print(f"{texte!r} : {hauteur} mm")
    finally:
        print("   (ligne traitée)")


for texte in ["12.5", "12,5", "", "n.m."]:
    essayer(texte)
Ce que tu vois
'12.5' : 12.5 mm (ligne traitée) '12,5' : 12.5 mm (ligne traitée) '' : illisible (ligne traitée) 'n.m.' : illisible (ligne traitée)
  • try : le code qui peut échouer, et seulement lui.
  • except ValueError : ce qu'on fait si cette exception survient ; les autres continuent leur chemin.
  • else : ce qui ne s'exécute que si try a réussi. On y met la suite, plutôt que dans try, pour ne pas intercepter par erreur une exception levée par cette suite.
  • finally : ce qui s'exécute dans tous les cas (libérer une ressource, écrire une trace). Pour un fichier, with le fait déjà (Pro, épisode 13).

Lever une exception claire : raise. Une fonction qui reçoit une valeur impossible ne doit pas renvoyer n'importe quoi : elle lève une exception avec un message utile.

Code
VALEURS_ABSENTES = ("", "n.m.")


def lire_taille(texte):
    """Convertit une hauteur écrite « 12,5 » ou « 12.5 » en nombre ; lève ValueError sinon."""
    texte = texte.strip()
    if texte in VALEURS_ABSENTES:
        raise ValueError(f"hauteur absente : {texte!r}")
    try:
        return float(texte.replace(",", "."))
    except ValueError:
        raise ValueError(f"hauteur illisible : {texte!r}") from None


for texte in ["12,5", " 40.0 ", "n.m.", "douze"]:
    try:
        print(lire_taille(texte))
    except ValueError as erreur:
        print("Écartée :", erreur)
print(lire_taille("n.m."))
Ce que tu vois
12.5 40.0 Écartée : hauteur absente : 'n.m.' Écartée : hauteur illisible : 'douze' Traceback (most recent call last): File "e9_lire_taille.py", line 20, in <module> print(lire_taille("n.m.")) File "e9_lire_taille.py", line 8, in lire_taille raise ValueError(f"hauteur absente : {texte!r}") ValueError: hauteur absente : 'n.m.'
  • raise ValueError("…") lève l'exception avec son message ; except ValueError as erreur récupère l'objet, dont le texte est le message.
  • raise … from None remplace l'exception d'origine (le message anglais de float) par la nôtre, sans afficher les deux.
  • La fonction lève ; c'est l'appelant qui décide quoi faire : ici, écarter la ligne et le dire. La dernière ligne, sans try, montre que l'erreur reste visible si personne ne la traite.

Le piège : intercepter trop large.

Code
def lire(texte):
    try:
        return flaot(texte.replace(",", "."))
    except:
        return 0.0


print(lire("12,5"))
print(lire("40,0"))
Ce que tu vois
0.0 0.0

Tout renvoie 0 : flaot est une faute de frappe (NameError), mais le except: nu intercepte tout, y compris cette erreur de programmation. Le bug devient silencieux, et le jardin ne pousse plus. On intercepte toujours une exception précise (except ValueError:), là où l'on sait quoi en faire.

Le cas laissé en suspens au Pro. L'écart type d'échantillon demande au moins deux valeurs (Pro, épisode 11) :

Code
import statistics

print(round(statistics.stdev([12.0, 14.5, 17.0]), 2))
try:
    print(statistics.stdev([12.0]))
except statistics.StatisticsError as erreur:
    print("Impossible :", erreur)
print(statistics.stdev([12.0]))

Affiche 2.5, puis Impossible : stdev requires at least two data points (« l'écart type demande au moins deux valeurs »), puis, sans try, s'arrête sur :

Message d'erreur
statistics.StatisticsError: stdev requires at least two data points

statistics.StatisticsError est une exception propre au module statistics : on l'intercepte par son nom complet.

Vérifier avant, ou essayer puis intercepter ? Pour un fichier, tu as appris à vérifier avant (chemin.exists(), Pro, épisode 13). Pour une conversion, essayer puis intercepter est plus simple : il y a trop de façons d'écrire un nombre faux pour toutes les tester avant.

Machine à exceptions · essayer(texte)
Ce qui s'affiche

[3]Recherche

Ce que disent les sources

  • Tutoriel officiel de Python, chapitre 8 — Dans un try, si une exception survient, le reste du bloc est sauté et la première clause except correspondante s'exécute ; il vaut mieux placer la suite dans else que dans try, pour ne pas intercepter une exception qu'on ne voulait pas ; finally s'exécute dans tous les cas ; raise seul relève l'exception en cours ; from indique la cause, from None supprime le chaînage [1].
  • Tutoriel officiel de Python — Intercepter Exception, ou toutes les exceptions avec un except nu, peut masquer des bogues : il est recommandé d'être aussi précis que possible et de laisser remonter les exceptions imprévues [1].
  • Tests de ce livre — statistics.stdev([12.0]) lève StatisticsError: stdev requires at least two data points sous Python 3.12.13, 3.15.0 et 3.15.2 (journal de test).
[4]Mythes
« Un try autour de tout le programme, et plus jamais d'erreur. »
plus jamais d'erreur visible, mais des résultats faux en silence ; on protège la ligne qui peut échouer, avec l'exception précise.
« Une exception, c'est un bug. »
c'est souvent une situation prévue (une case vide, un fichier absent) ; le bug, c'est de ne pas la traiter, ou de la cacher.
« Il vaut mieux renvoyer 0 ou None que lever une exception. »
un 0 se mélange aux vraies mesures ; une exception claire oblige l'appelant à décider.
[5]Quiz

1. Dans e9_essayer.py, que s'affiche-t-il pour l'entrée "12,5" ?

la virgule est remplacée avant la conversion, qui réussit : else, puis finally (sortie réelle).

2. Pourquoi except: tout seul est-il dangereux ?

flaot a été avalé, et toutes les hauteurs sont devenues 0 (sortie réelle).

3. Que fait raise ValueError("hauteur absente") dans une fonction ?

l'appelant l'intercepte avec except ValueError, ou le programme s'arrête.
[6]Kit

🎒 Ton kit — Épisode 9

25 min

Exercice : une conversion qui explique

  1. Écris lire_taille(texte) : elle renvoie un nombre pour « 12,5 » ou « 12.5 », lève une ValueError claire pour une case vide ou « n.m. », et une autre pour un texte illisible.
  2. Appelle-la sur toutes les hauteurs du cahier ; écarte les cases impossibles avec try / except ValueError et affiche la raison.
  3. Glisse volontairement une faute de frappe dans le try, avec un except: nu, puis avec except ValueError: : compare.
  4. Protège le calcul d'un écart type contre une série d'une seule valeur.

Code à copier : e9_lire_taille.py.

Checklist

  • Mon try ne contient que la ligne qui peut échouer.
  • J'intercepte une exception précise, jamais avec except: nu.
  • Mes fonctions lèvent des exceptions avec un message utile.
Vigilanceune exception interceptée doit laisser une trace (un message, une ligne de rapport) ; une erreur avalée en silence est le pire des bugs.
[7]Journal

✍️ Journal de bord

Dans tes TP, quelles situations « prévues mais anormales » (case vide, fichier manquant, mauvaise unité) mériteraient une exception claire plutôt qu'un arrêt brutal ?

[8]Sources
  1. Python Software Foundation, Tutoriel Python — 8. Erreurs et exceptions, documentation de Python 3.15.2, consulté le 30/10/2026.
  2. Programmes exécutés le 30/10/2026 sous Python 3.12.13, 3.15.0 et 3.15.2 (journaux de test de ce livre).
10
S5 · JEUDI · Pratiquer

Une lecture qui ne plante plus

Lire tout le cahier du jardin, garder les lignes valides, et produire un rapport précis des lignes écartées.

⏱ 30 min de pratique · 5 min de quiz
[1]Scénario
Yanis · Jeudi, 15 h

Le cahier compte 24 lignes. Demain, la doctorante lui enverra celui de l'an dernier : 300 lignes, remplies par vingt bénévoles différents. Yanis ne peut pas corriger à la main chaque virgule oubliée. Il lui faut une lecture qui lit tout ce qui est lisible… et qui dit précisément ce qu'elle a laissé de côté.

[2]Méthode

« Attraper au plus près, signaler toujours »

  1. La fonction qui sait convertir lève (lire_taille, épisode 9) : elle ne sait pas ce qu'il faut faire d'une case vide.
  2. La fonction qui parcourt le fichier attrape, ligne par ligne : elle sait qu'il faut écarter la ligne et continuer.
  3. Chaque ligne écartée est signalée : son numéro dans le fichier et la raison.
  4. On vérifie sur un petit fichier construit pour l'occasion (une ligne valide, une invalide).

lecture.py, premier module de « Pousse » :

Code
"""Lecture du cahier du jardin : conversion des hauteurs et rapport des lignes écartées."""

import csv
from pathlib import Path

from modele import Mesure

VALEURS_ABSENTES = ("", "n.m.")


def lire_taille(texte: str) -> float:
    """Convertit une hauteur écrite « 12,5 » ou « 12.5 » en nombre ; lève ValueError sinon."""
    texte = texte.strip()
    if texte in VALEURS_ABSENTES:
        raise ValueError(f"hauteur absente : {texte!r}")
    try:
        return float(texte.replace(",", "."))
    except ValueError:
        raise ValueError(f"hauteur illisible : {texte!r}") from None


def lire_cahier(chemin: Path) -> tuple[list[Mesure], list[str]]:
    """Lit le cahier ; renvoie (mesures valides, rapport des lignes écartées)."""
    mesures: list[Mesure] = []
    rapport: list[str] = []
    with open(chemin, encoding="utf-8") as fichier:
        for numero, ligne in enumerate(csv.DictReader(fichier, delimiter=";"), start=2):
            try:
                hauteur = lire_taille(ligne["hauteur_mm"])
                mesure = Mesure(int(ligne["semaine"]), ligne["date"], ligne["parcelle"], hauteur)
            except ValueError as erreur:
                rapport.append(f"ligne {numero} : {erreur}")
            else:
                mesures.append(mesure)
    return mesures, rapport
  • Mesure vient du module modele (épisode 11) : pour l'instant, retiens qu'elle range les quatre champs d'une ligne.
  • enumerate(…, start=2) numérote les éléments d'une séquence : à chaque tour, il donne le couple (numéro, élément). On commence à 2, car la ligne 1 du fichier est l'en-tête.
  • int(ligne["semaine"]) peut aussi lever une ValueError (une semaine « 7b ») : elle est interceptée par le même except.
  • Les écritures texte: str, -> float ou mesures: list[Mesure] sont des annotations de type : elles disent ce qu'on attend, sans rien changer à l'exécution. Elles sont expliquées à l'épisode 15 ; pour l'instant, lis-les comme des commentaires.

Avec le programme rapport.py (épisode 16), sur le cahier du jardin :

Ce que tu vois
2 lignes écartées : ligne 14 : hauteur absente : 'n.m.' ligne 17 : hauteur absente : ''

Les lignes 14 et 17 du fichier sont bien les cases « n.m. » de la parcelle A (semaine 7) et vide de la parcelle B (semaine 8), repérées à l'épisode 2.

[3]Pourquoi ça marche

Chaque fonction fait ce qu'elle sait faire : lire_taille connaît les formats d'un nombre, lire_cahier connaît la structure du fichier. L'exception transporte l'information de l'une à l'autre, message compris. Le rapport rend l'écart visible : le jour où une ligne disparaît de l'analyse, on sait laquelle et pourquoi. C'est ce que demandera un encadrant de laboratoire : « Combien de mesures avez-vous exclues, et pourquoi ? »

À retenirlever là où l'on détecte, attraper là où l'on sait quoi faire, et toujours signaler.
[4]Entraînement

Bon réflexe ou piège ?

Je mets tout le contenu de la boucle de lecture dans un seul try avec except Exception.
tu avales aussi les erreurs de ton propre code ; intercepte ValueError, là où elle peut survenir.
Je note le numéro de ligne et la raison de chaque ligne écartée.
on peut vérifier dans le fichier et corriger la source.
Je remplace les cases vides par 0 pour ne rien perdre.
un 0 est une fausse mesure ; il fausse la croissance moyenne.
Je teste ma lecture sur un petit fichier construit exprès, avec une ligne invalide.
tu connais le résultat attendu à l'avance.
Ma fonction de lecture affiche les erreurs avec print et ne renvoie que les mesures.
l'appelant ne peut plus rien faire du rapport (le compter, l'écrire dans un fichier) ; renvoie-le.
[5]Quiz

1. Pourquoi enumerate(…, start=2) et non start=1 ?

le numéro affiché doit être celui de la ligne dans le fichier, que l'on peut ouvrir pour vérifier.

2. Une ligne contient la semaine « 7b ». Que se passe-t-il ?

int("7b") lève une ValueError, interceptée par le même except que les hauteurs.

3. Que renvoie lire_cahier ?

un tuple de deux listes : l'appelant décide quoi faire du rapport.
[6]Kit

🎒 Ton kit — Épisode 10

30 min

Exercice : la lecture du jardin

  1. Crée le dossier pousse et, dedans, lecture.py (et modele.py avec la seule classe Mesure, épisode 11, si tu le lis dans l'ordre : copie-la depuis le kit de l'épisode 16).
  2. Construis un petit cahier de 4 lignes, dont une invalide, et vérifie la sortie de lire_cahier.
  3. Lance-le sur mesures-jardin.csv : 22 mesures, 2 lignes écartées (14 et 17).
  4. Ajoute une ligne « 7b » à une copie du cahier et vérifie qu'elle est signalée.

Code à copier : lecture.py.

Checklist

  • Ma lecture ne s'arrête plus sur une ligne invalide.
  • Chaque ligne écartée est signalée avec son numéro et sa raison.
  • J'ai vérifié sur un petit fichier construit exprès.
Vigilancen'écris jamais tes essais dans le cahier d'origine ; travaille sur une copie.
[7]Journal

✍️ Journal de bord

Combien de mesures ton TP du Pro avait-il exclues ? L'aurais-tu su sans un rapport comme celui-ci ?

[8]Sources
  1. Python Software Foundation, Fonctions natives — enumerate, consulté le 30/10/2026.
  2. Programmes exécutés le 30/10/2026 sous Python 3.12.13, 3.15.0 et 3.15.2 (journaux de test de ce livre).
Semaine 6 · Les classes
11
S6 · MARDI · Comprendre

Des données qui savent ce qu'elles sont

Créer des classes et des objets, avec leurs attributs et leurs méthodes ; un affichage utile avec __repr__ ; les classes de données avec @dataclass.

⏱ 30 min de lecture · 5 min de quiz
[1]Scénario
Yanis · Mardi, 18 h

mesure[3], c'était la hauteur ou la parcelle ? Et dans le dictionnaire, la clé s'écrivait "hauteur" ou "hauteur_mm" ? Yanis se trompe de champ pour la troisième fois de la soirée. Le doctorant passe derrière lui : « Fais-en une classe. Une mesure saura ce qu'elle contient, et ton éditeur aussi. »

[2]Concept

Classes et objets

Une classe est un modèle ; un objet (ou instance) est un exemplaire construit sur ce modèle. Chaque objet a ses attributs (ses données) et partage les méthodes (les fonctions) de sa classe.

Une classe à la main.

Code
class Mesure:
    """Une mesure du cahier du jardin."""

    def __init__(self, semaine, date, parcelle, hauteur):
        self.semaine = semaine
        self.date = date
        self.parcelle = parcelle
        self.hauteur = hauteur


m = Mesure(1, "2026-03-30", "A", 8.0)
print(m.parcelle, m.hauteur)
m.hauteur = 8.5
print(m.hauteur)
print(m)
Ce que tu vois
A 8.0 8.5 <__main__.Mesure object at 0x…>
  • class Mesure: définit la classe (nom en CamelCase, PEP 8, Pro, épisode 9).
  • __init__ est appelée à la création de chaque objet ; self désigne l'objet en train d'être construit (le nom self est une convention, très fortement suivie).
  • m.hauteur lit un attribut ; m.hauteur = 8.5 le modifie.
  • L'affichage par défaut est inutile : <__main__.Mesure object at 0x…> (l'adresse change à chaque exécution).

Une classe de données : @dataclass. Pour une classe qui sert surtout à ranger des données, le module dataclasses écrit __init__, l'affichage et la comparaison à ta place :

Code
from dataclasses import dataclass


@dataclass
class Mesure:
    """Une mesure du cahier : semaine, date (AAAA-MM-JJ), parcelle, hauteur en mm."""

    semaine: int
    date: str
    parcelle: str
    hauteur: float


m = Mesure(1, "2026-03-30", "A", 8.0)
print(m)
print(m == Mesure(1, "2026-03-30", "A", 8.0))
print(Mesure(1, "2026-03-30", "A", "huit"))
Ce que tu vois
Mesure(semaine=1, date='2026-03-30', parcelle='A', hauteur=8.0) True Mesure(semaine=1, date='2026-03-30', parcelle='A', hauteur='huit')
  • semaine: int déclare un champ : son nom et le type attendu.
  • print(m) affiche enfin les champs ; == compare champ par champ.
  • La dernière ligne montre que Python ne vérifie pas les types : "huit" est accepté sans broncher. L'épisode 15 donnera un outil pour l'attraper.

Des méthodes : une parcelle qui gère ses mesures.

Code
from dataclasses import dataclass


@dataclass
class Mesure:
    """Une mesure du cahier : semaine, date (AAAA-MM-JJ), parcelle, hauteur en mm."""

    semaine: int
    date: str
    parcelle: str
    hauteur: float


class Parcelle:
    """Une parcelle du jardin et ses mesures."""

    def __init__(self, nom, paillee):
        self.nom = nom
        self.paillee = paillee
        self.mesures = []

    def __repr__(self):
        return f"Parcelle({self.nom!r}, paillee={self.paillee}, mesures={len(self.mesures)})"

    def ajouter(self, mesure):
        """Ajoute une mesure de cette parcelle ; lève ValueError si elle vient d'une autre parcelle."""
        if mesure.parcelle != self.nom:
            raise ValueError(f"mesure de la parcelle {mesure.parcelle!r} ajoutée à {self.nom!r}")
        self.mesures.append(mesure)

    def hauteur_finale(self):
        """Renvoie la hauteur de la dernière semaine mesurée, ou None sans mesure."""
        if len(self.mesures) == 0:
            return None
        return max(self.mesures, key=lambda m: m.semaine).hauteur


a = Parcelle("A", paillee=True)
b = Parcelle("B", paillee=False)
a.ajouter(Mesure(2, "2026-04-06", "A", 14.5))
a.ajouter(Mesure(1, "2026-03-30", "A", 8.0))
print(a, b)
print(a.hauteur_finale(), b.hauteur_finale())
a.ajouter(Mesure(1, "2026-03-30", "B", 7.5))
Ce que tu vois
Parcelle('A', paillee=True, mesures=2) Parcelle('B', paillee=False, mesures=0) 14.5 None Traceback (most recent call last): File "e11_parcelle.py", line 44, in <module> a.ajouter(Mesure(1, "2026-03-30", "B", 7.5)) File "e11_parcelle.py", line 28, in ajouter raise ValueError(f"mesure de la parcelle {mesure.parcelle!r} ajoutée à {self.nom!r}") ValueError: mesure de la parcelle 'B' ajoutée à 'A'
  • self.mesures = [] est créé dans __init__ : chaque parcelle a sa propre liste.
  • __repr__ choisit l'affichage d'un objet ; il est utilisé par print et dans les listes.
  • ajouter protège la parcelle : une mesure d'une autre parcelle lève une ValueError (épisode 9).
  • hauteur_finale utilise max avec une clé (épisode 3) : peu importe l'ordre d'ajout, c'est la semaine la plus tardive qui compte.

Le piège de la liste partagée. Une valeur par défaut modifiable dans une dataclass est refusée :

Code
from dataclasses import dataclass


@dataclass
class Parcelle:
    nom: str
    mesures: list = []

Affiche (dernière ligne de la trace) :

Message d'erreur
ValueError: mutable default <class 'list'> for field mesures is not allowed: use default_factory

« Valeur par défaut modifiable de type liste non autorisée pour le champ mesures : utilise default_factory ». Une même liste serait partagée par toutes les parcelles. On écrit mesures: list = field(default_factory=list) (avec from dataclasses import field), ou on crée la liste dans __init__, comme Parcelle.

Classe ou fonction ? Une classe quand des données et les opérations qui les concernent vont ensemble (une parcelle et ses mesures) ; une simple fonction quand on transforme des données sans état à garder (lire_taille).

Suivre les variables · deux parcelles et leurs attributs
Ce qui s'affiche

[3]Recherche

Ce que disent les sources

  • Tutoriel officiel de Python, chapitre 9 — Une classe regroupe des données et des fonctionnalités et crée un nouveau type d'objet ; __init__ initialise chaque instance ; le premier paramètre d'une méthode s'appelle self par convention, sans signification particulière pour Python ; une liste déclarée au niveau de la classe est partagée par toutes les instances : il faut la créer dans __init__ [1].
  • Documentation officielle de dataclasses — Le décorateur @dataclass (Python 3.7, PEP 557) génère __init__, __repr__ et __eq__ à partir des champs annotés ; une valeur par défaut modifiable lève ValueError : on utilise field(default_factory=list) ; le décorateur ignore en grande partie les types [2].
  • Tests de ce livre — Mesure(1, "2026-03-30", "A", "huit") est accepté sans erreur à l'exécution (journal de test).
[4]Mythes
« En Python, il faut tout mettre dans des classes. »
lire_taille reste une fonction ; une classe se justifie quand des données et leurs opérations vont ensemble.
« Une classe, c'est un dictionnaire avec une autre syntaxe. »
ses champs sont fixés, elle a des méthodes, et ton éditeur sait compléter m.hauteur ; une faute de frappe sur un attribut se voit tout de suite.
« Avec une dataclass, Python vérifie les types. »
il accepte "huit" comme hauteur ; les types déclarés servent aux outils et aux lecteurs.
[5]Quiz

1. Que fait @dataclass au-dessus de class Mesure ?

Mesure(semaine=1, …) s'affiche et se compare sans code supplémentaire (sortie réelle).

2. Pourquoi self.mesures = [] est-il écrit dans __init__ ?

une liste au niveau de la classe serait partagée par toutes les parcelles.

3. Que renvoie Parcelle("B", paillee=False).hauteur_finale() ?

sans mesure, la méthode renvoie None (sortie réelle : 14.5 None).
[6]Kit

🎒 Ton kit — Épisode 11

25 min

Exercice : les mesures deviennent des objets

  1. Écris la dataclass Mesure et crée trois mesures ; affiche-les et compare-en deux.
  2. Écris la classe Parcelle avec ajouter et hauteur_finale, puis son __repr__.
  3. Vérifie qu'une mesure de la parcelle B ne peut pas entrer dans la parcelle A.
  4. Essaie le piège de la liste par défaut, lis le message, corrige avec field(default_factory=list).

Code à copier : e11_dataclass.py et e11_parcelle.py.

Checklist

  • Je sais créer une classe, ses objets et ses méthodes.
  • Mes objets s'affichent de façon utile.
  • Je sais quand une dataclass suffit.
Vigilanceune valeur modifiable (liste, dictionnaire) se crée dans __init__ ou avec default_factory, jamais comme valeur partagée.
[7]Journal

✍️ Journal de bord

Dans ton TP du Pro, quelles données auraient gagné à devenir des objets ? Quelles opérations seraient devenues leurs méthodes ?

[8]Sources
  1. Python Software Foundation, Tutoriel Python — 9. Classes, documentation de Python 3.15.2, consulté le 30/10/2026.
  2. Python Software Foundation, dataclasses — Data Classes, consulté le 30/10/2026.
12
S6 · JEUDI · Pratiquer

Modéliser le jardin

Des noms aux classes, des verbes aux méthodes : le modèle du jardin, avec ses mesures, ses parcelles et le jardin lui-même.

⏱ 30 min de pratique · 5 min de quiz
[1]Scénario
Yanis · Jeudi, 13 h

Awa lit la description du projet écrite par Yanis : « Le jardin a des parcelles, paillées ou nues. Chaque parcelle reçoit des mesures hebdomadaires. On veut connaître la hauteur finale et la croissance de chaque parcelle. » Elle souligne au crayon : « Tes classes sont déjà là. Il suffit de les lire. »

[2]Méthode

« Des noms aux classes, des verbes aux méthodes »

  1. Écrire la description du problème en quelques phrases.
  2. Souligner les noms : jardin, parcelle, mesure, hauteur, semaine. Ceux qui ont plusieurs informations deviennent des classes ; les autres, des attributs.
  3. Souligner les verbes : ajouter une parcelle, recevoir une mesure, connaître la hauteur finale. Ils deviennent des méthodes de la classe qui possède les données.
  4. Garder en fonction ce qui ne possède aucune donnée (lire un fichier, comparer).
  5. Essayer le modèle dans un petit script avant de l'utiliser partout.

modele.py (version de cet épisode) :

Code
"""Modèle du jardin : mesures, parcelles et jardin."""

from dataclasses import dataclass


@dataclass
class Mesure:
    """Une mesure du cahier : semaine, date (AAAA-MM-JJ), parcelle, hauteur en mm."""

    semaine: int
    date: str
    parcelle: str
    hauteur: float


class Parcelle:
    """Une parcelle du jardin et ses mesures."""

    def __init__(self, nom: str, paillee: bool) -> None:
        self.nom = nom
        self.paillee = paillee
        self.mesures: list[Mesure] = []

    def __repr__(self) -> str:
        return f"Parcelle({self.nom!r}, paillee={self.paillee}, mesures={len(self.mesures)})"

    def ajouter(self, mesure: Mesure) -> None:
        """Ajoute une mesure de cette parcelle ; lève ValueError si elle vient d'une autre parcelle."""
        if mesure.parcelle != self.nom:
            raise ValueError(f"mesure de la parcelle {mesure.parcelle!r} ajoutée à {self.nom!r}")
        self.mesures.append(mesure)

    def hauteur_finale(self) -> float | None:
        """Renvoie la hauteur de la dernière semaine mesurée, ou None sans mesure."""
        if len(self.mesures) == 0:
            return None
        return max(self.mesures, key=lambda m: m.semaine).hauteur


class Jardin:
    """Le jardin : ses parcelles, rangées par nom."""

    def __init__(self) -> None:
        self.parcelles: dict[str, Parcelle] = {}

    def ajouter_parcelle(self, nom: str, paillee: bool) -> None:
        """Crée une parcelle vide."""
        self.parcelles[nom] = Parcelle(nom, paillee)

    def ajouter_mesure(self, mesure: Mesure) -> None:
        """Range une mesure dans sa parcelle ; lève ValueError si la parcelle est inconnue."""
        if mesure.parcelle not in self.parcelles:
            raise ValueError(f"parcelle inconnue : {mesure.parcelle!r}")
        self.parcelles[mesure.parcelle].ajouter(mesure)

Un essai :

Code
from modele import Jardin, Mesure

jardin = Jardin()
jardin.ajouter_parcelle("A", paillee=True)
jardin.ajouter_parcelle("B", paillee=False)
jardin.ajouter_mesure(Mesure(1, "2026-03-30", "A", 8.0))
jardin.ajouter_mesure(Mesure(1, "2026-03-30", "B", 7.5))
jardin.ajouter_mesure(Mesure(2, "2026-04-06", "A", 14.5))
for nom, parcelle in jardin.parcelles.items():
    print(nom, parcelle, parcelle.hauteur_finale())
jardin.ajouter_mesure(Mesure(1, "2026-03-30", "C", 5.0))

Sortie réelle :

Ce que tu vois
A Parcelle('A', paillee=True, mesures=2) 14.5 B Parcelle('B', paillee=False, mesures=1) 7.5 Traceback (most recent call last): File "essai_jardin.py", line 11, in <module> jardin.ajouter_mesure(Mesure(1, "2026-03-30", "C", 5.0)) File "modele.py", line 53, in ajouter_mesure raise ValueError(f"parcelle inconnue : {mesure.parcelle!r}") ValueError: parcelle inconnue : 'C'
  • Le jardin range ses parcelles dans un dictionnaire (Pro, épisode 8) : on retrouve une parcelle par son nom.
  • ajouter_mesure délègue à la parcelle : chaque objet fait ce qui le concerne.
  • Une mesure d'une parcelle inconnue lève une ValueError claire, au lieu d'une KeyError obscure.
Remets les lignes dans l'ordre · clique les lignes une par une
Ton programme
Lignes à placer
[3]Pourquoi ça marche

Un modèle qui suit la description du problème se relit comme elle : un encadrant qui n'a jamais vu ton code comprend jardin.parcelles["A"].hauteur_finale(). Chaque classe protège ses données (une parcelle refuse la mesure d'une autre), et chaque règle n'est écrite qu'à un seul endroit. L'essai dans un petit script vérifie le modèle avant de le brancher sur 300 lignes de cahier.

À retenirnoms → classes et attributs ; verbes → méthodes ; ce qui n'a pas de données reste une fonction ; on essaie le modèle tout seul.
[4]Entraînement

Bon réflexe ou piège ?

Je crée une classe Hauteur qui ne contient qu'un nombre.
un nombre seul est un attribut ; une classe se justifie par plusieurs informations ou des opérations.
ajouter_mesure du jardin appelle ajouter de la parcelle.
chaque objet s'occupe de ses propres données.
Je mets la lecture du fichier CSV dans la classe Parcelle.
la lecture concerne le fichier, pas une parcelle ; elle reste dans lecture.py.
J'essaie mon modèle dans un petit script avant de le brancher sur le vrai cahier.
une erreur de modèle se voit sur trois mesures.
Pour aller vite, je modifie directement jardin.parcelles["A"].mesures.append(...).
tu contournes la vérification d'ajouter ; passe par la méthode.
[5]Quiz

1. Dans la description « chaque parcelle reçoit des mesures », que devient « reçoit » ?

un verbe qui agit sur les données d'une parcelle devient une de ses méthodes.

2. Que se passe-t-il avec jardin.ajouter_mesure(Mesure(1, "2026-03-30", "C", 5.0)) ?

la méthode vérifie avant d'accéder au dictionnaire (sortie réelle).

3. Pourquoi lire_cahier reste-t-elle une fonction ?

elle transforme un fichier en mesures, sans état.
[6]Kit

🎒 Ton kit — Épisode 12

30 min

Exercice : le modèle du jardin

  1. Écris la description de « Pousse » en 3 phrases et souligne noms et verbes.
  2. Complète modele.py avec Jardin, ajouter_parcelle et ajouter_mesure.
  3. Lance l'essai, puis branche le modèle sur lire_cahier (épisode 10) : 11 mesures par parcelle.
  4. Ajoute à Parcelle une méthode nb_mesures() et vérifie-la.

Code à copier : modele.py et essai_jardin.py.

Checklist

  • Mes classes viennent des noms de la description, mes méthodes de ses verbes.
  • Mon modèle refuse les mesures incohérentes avec un message clair.
  • Je l'ai essayé seul avant de le brancher sur le cahier.
Vigilanceun modèle trop riche dès le départ est difficile à changer ; n'ajoute une classe ou une méthode que lorsqu'un besoin réel la demande.
[7]Journal

✍️ Journal de bord

Quelle classe de ton modèle a été la plus difficile à nommer ? Qu'est-ce que cela dit de ta compréhension du problème ?

[8]Sources
  1. Python Software Foundation, Tutoriel Python — 9. Classes, consulté le 30/10/2026.
  2. Programmes exécutés le 30/10/2026 sous Python 3.12.13, 3.15.0 et 3.15.2 (journaux de test de ce livre).
Semaine 7 · Les tests automatisés
13
S7 · MARDI · Comprendre

Des tests qui tournent tout seuls

Écrire des tests avec pytest, lire son rapport, tester une exception attendue, un fichier temporaire et des nombres à virgule.

⏱ 30 min de lecture · 5 min de quiz
[1]Scénario
Yanis · Mardi, 21 h

Pour accepter « 12.5 » en plus de « 12,5 », Yanis a retouché lire_taille. Il relance rapport.py : les chiffres ont l'air bons. Trois jours plus tard, Awa remarque que la croissance de la parcelle B a changé. Ses assert du Pro (Pro, épisode 2) auraient vu le problème… s'il avait pensé à les relancer tous.

[2]Concept

Les tests automatisés avec pytest

Un test automatisé est une fonction qui vérifie un comportement attendu. pytest les trouve, les lance tous en une commande, et explique précisément ceux qui échouent.

Installer et lancer. Dans l'environnement du projet (Pro, épisode 14) : python -m pip install pytest, puis, dans le dossier pousse : python -m pytest. pytest cherche les fichiers test_*.py et, dedans, les fonctions test_*.

test_lecture.py :

Code
import pytest

from lecture import lire_cahier, lire_taille


def test_virgule_et_point():
    assert lire_taille("12,5") == 12.5
    assert lire_taille(" 40.0 ") == 40.0


@pytest.mark.parametrize("texte", ["", "n.m.", "  "])
def test_hauteur_absente(texte):
    with pytest.raises(ValueError, match="absente"):
        lire_taille(texte)


def test_hauteur_illisible():
    with pytest.raises(ValueError, match="illisible"):
        lire_taille("douze")


def test_cahier_avec_lignes_ecartees(tmp_path):
    cahier = tmp_path / "cahier.csv"
    cahier.write_text("semaine;date;parcelle;hauteur_mm\n"
                      "1;2026-03-30;A;8,0\n"
                      "2;2026-04-06;A;n.m.\n"
                      "3;2026-04-13;A;22,0\n", encoding="utf-8")
    mesures, ecartees = lire_cahier(cahier)
    assert [m.hauteur for m in mesures] == [8.0, 22.0]
    assert ecartees == ["ligne 3 : hauteur absente : 'n.m.'"]
  • Un test = une fonction test_… avec un ou plusieurs assert.
  • pytest.raises(ValueError, match="absente") vérifie qu'une exception est levée, et que son message contient « absente ». Sans exception, le test échoue.
  • @pytest.mark.parametrize lance le même test avec plusieurs valeurs : ici trois.
  • tmp_path : pytest donne au test un dossier temporaire, neuf, rien qu'à lui. On y écrit un petit cahier : les vrais fichiers ne sont jamais touchés (règle des copies).

test_modele.py (extrait) utilise pytest.approx pour comparer des nombres à virgule sans être piégé par les arrondis (Boost, épisode 1) : assert parcelle.croissance_hebdo() == pytest.approx([6.5, 8.5]).

Le rapport, quand tout va bien (python -m pytest, sortie réelle ; la durée varie) :

Rapport de pytest
............. [100%] 13 passed in 0.04s

Un point par test réussi : 13 tests, en quelques centièmes de seconde.

Le rapport, quand un bug se glisse. Yanis décale par erreur la numérotation des lignes (start=1 au lieu de start=2) :

Rapport de pytest
.....F [100%] =================================== FAILURES =================================== _______________________ test_cahier_avec_lignes_ecartees _______________________ tmp_path = PosixPath('/tmp/pytest-of-…/pytest-N/test_cahier_avec_lignes_ecarte0') def test_cahier_avec_lignes_ecartees(tmp_path): cahier = tmp_path / "cahier.csv" cahier.write_text("semaine;date;parcelle;hauteur_mm\n" "1;2026-03-30;A;8,0\n" "2;2026-04-06;A;n.m.\n" "3;2026-04-13;A;22,0\n", encoding="utf-8") mesures, ecartees = lire_cahier(cahier) assert [m.hauteur for m in mesures] == [8.0, 22.0] > assert ecartees == ["ligne 3 : hauteur absente : 'n.m.'"] E assert ["ligne 2 : h...nte : 'n.m.'"] == ["ligne 3 : h...nte : 'n.m.'"] E E At index 0 diff: "ligne 2 : hauteur absente : 'n.m.'" != "ligne 3 : hauteur absente : 'n.m.'" E Use -v to get more diff test_lecture.py:30: AssertionError =========================== short test summary info ============================ FAILED test_lecture.py::test_cahier_avec_lignes_ecartees - assert ["ligne 2 :... 1 failed, 5 passed in 0.03s

Le F désigne le test qui échoue ; le > montre la ligne fautive ; les lignes E comparent l'obtenu (ligne 2) et l'attendu (ligne 3). Le bug est trouvé avant d'être publié.

Un test qui passe ne prouve pas que le code est juste : il prouve que le code fait ce que ce test vérifie. D'où l'importance de choisir ses cas : le cas connu, les cas limites (vide, une seule valeur), les exceptions.

Résultats de tests simulés · rapports réels de pytest 9.2.0

Lignes surlignées : F (le test qui échoue), > (la ligne fautive), E (obtenu contre attendu).

[3]Recherche

Ce que disent les sources

  • Documentation officielle de pytest (version 9.2.0) — pytest s'installe dans un environnement virtuel, pas dans le Python du système ; il lance les fichiers test_*.py ou *_test.py du dossier courant et de ses sous-dossiers, et les fonctions préfixées par test_ ; il utilise de simples assert et, en cas d'échec, montre les valeurs intermédiaires ; pytest.raises vérifie qu'une exception est levée ; la fixture tmp_path donne à chaque test un dossier temporaire unique [1].
  • Programme de NSI, classe de première — Utiliser des jeux de tests ; « un jeu de tests réussi ne garantit pas qu'un programme soit correct » [2].
  • Tests de ce livre — pytest 9.2.0, installé depuis PyPI le 30/10/2026, sous Python 3.12.13, 3.15.0 et 3.15.2 : 13 tests réussis ; les trois bugs introduits sont tous détectés (journaux de test).
[4]Mythes
« Les tests, c'est pour les grosses entreprises. »
13 tests ont suffi à protéger « Pousse » ; ils se lancent en une commande, en une fraction de seconde.
« Un test qui passe prouve que le code est juste. »
il prouve seulement ce qu'il vérifie ; un cas oublié reste un bug possible.
« Tester, c'est perdre du temps. »
c'est le temps de relancer tous les assert à la main, gagné à chaque modification, et les bugs trouvés avant la publication.
[5]Quiz

1. Quel fichier pytest exécute-t-il automatiquement ?

il cherche les fichiers test_*.py (ou *_test.py).

2. À quoi sert tmp_path ?

le test écrit ses fichiers dans ce dossier ; les vrais ne sont jamais touchés.

3. Avec le bug « virgule oubliée », combien de tests de test_lecture.py échouent ?

test_virgule_et_point et test_cahier_avec_lignes_ecartees ; un bug peut faire échouer plusieurs tests (rapport réel du widget).
[6]Kit

🎒 Ton kit — Épisode 13

30 min

Exercice : les premiers tests de « Pousse »

  1. Installe pytest dans l'environnement du projet et lance python -m pytest : 0 test trouvé.
  2. Écris test_lecture.py et fais-le passer.
  3. Introduis volontairement le bug « virgule oubliée » : lis le rapport, trouve la ligne >, puis corrige.
  4. Ajoute un test pour une semaine « 7b ».

Code à copier : test_lecture.py.

Checklist

  • pytest est installé dans l'environnement du projet.
  • Mes tests couvrent un cas connu, un cas limite et une exception.
  • Je sais lire un rapport d'échec (F, >, E).
Vigilanceun test ne doit jamais écrire dans tes vrais fichiers ; utilise tmp_path.
[7]Journal

✍️ Journal de bord

Quel bug de ton TP du Pro un test automatique aurait-il trouvé plus tôt ? Écris le test qui l'aurait attrapé.

[8]Sources
  1. pytest, Get Started, documentation de la version 9.2.0, consulté le 30/10/2026.
  2. Ministère de l'Éducation nationale, Programme de numérique et sciences informatiques de première générale, annexe (BO spécial n° 1 du 22 janvier 2019), consulté le 30/10/2026.
  3. pytest 9.2.0 installé depuis PyPI et exécuté le 30/10/2026 (journaux de test de ce livre).
14
S7 · JEUDI · Pratiquer

Le test d'abord

Rouge, vert, propre : écrire le test, le voir échouer, coder juste ce qu'il faut, le voir passer.

⏱ 30 min de pratique · 5 min de quiz
[1]Scénario
Yanis · Jeudi, 16 h

Il manque à « Pousse » la croissance de chaque parcelle, en millimètres par semaine. Yanis s'apprête à coder. Awa l'arrête : « Écris d'abord le test. Qu'est-ce que tu attends, exactement, quand une semaine manque ? » Il ne sait pas répondre tout de suite. C'est justement ce qu'il fallait trouver.

[2]Méthode

« Rouge, vert, propre »

  1. Écrire le test du comportement voulu, avec un cas calculé à la main (Boost, épisode 4) : mesures 8,0 (semaine 1), 14,5 (semaine 2), 31,5 (semaine 4) → croissances 6,5 puis (31,5 − 14,5) / 2 = 8,5 mm par semaine.
  2. Rouge : lancer le test, le voir échouer, pour la bonne raison.
  3. Vert : écrire le minimum de code pour le faire passer.
  4. Propre : relire, renommer, simplifier, en gardant tous les tests au vert.

Le test, écrit avant la méthode (test_modele.py) :

Code
def test_croissance_avec_semaine_manquante():
    parcelle = Parcelle("A", paillee=True)
    for semaine, hauteur in [(1, 8.0), (2, 14.5), (4, 31.5)]:
        parcelle.ajouter(Mesure(semaine, "", "A", hauteur))
    assert parcelle.croissance_hebdo() == pytest.approx([6.5, 8.5])

Rouge, étape 1 : la méthode n'existe pas encore (python -m pytest test_modele.py -k croissance, où -k choisit les tests dont le nom contient « croissance ») :

Rapport de pytest
=========================== short test summary info ============================ FAILED test_modele.py::test_croissance_avec_semaine_manquante - AttributeErro... 1 failed, 4 deselected in 0.02s

L'échec est le bon : AttributeError: 'Parcelle' object has no attribute 'croissance_hebdo'.

Rouge, étape 2 : une première version, qui soustrait deux hauteurs successives… et oublie l'écart entre les semaines :

Code
        return [tries[i + 1].hauteur - tries[i].hauteur for i in range(len(tries) - 1)]

Le test la refuse : assert [6.5, 17.0] == approx([6.5 ±....5 ± 8.5e-06]), avec le tableau « Obtained 17.0, Expected 8.5 ». Sans le test écrit avant, 17 mm par semaine serait passé inaperçu.

Vert, étape 3 : on divise par l'écart entre les semaines :

Code
    def croissance_hebdo(self) -> list[float]:
        """Renvoie la croissance en mm par semaine entre deux mesures successives."""
        tries = sorted(self.mesures, key=lambda m: m.semaine)
        return [(tries[i + 1].hauteur - tries[i].hauteur) / (tries[i + 1].semaine - tries[i].semaine)
                for i in range(len(tries) - 1)]
Rapport de pytest
. [100%] 1 passed, 4 deselected in 0.01s

Propre : la compréhension (épisode 1) est découpée sur deux lignes pour rester lisible ; le test test_parcelle_vide vérifie aussi qu'une parcelle sans mesure renvoie [].

[3]Pourquoi ça marche

Écrire le test d'abord oblige à décider ce qu'on veut avant de penser à comment le faire : c'est là que Yanis a dû trancher le cas de la semaine manquante. Voir le test échouer prouve qu'il teste vraiment quelque chose (un test qui passe avant d'avoir écrit le code ne teste rien). Et chaque modification ultérieure se fait sous la protection des tests : on peut nettoyer sans peur.

À retenirrouge pour la bonne raison, vert avec le minimum, propre sous protection.
[4]Entraînement

Bon réflexe ou piège ?

J'écris le test après avoir codé, en recopiant ce que le code renvoie.
le test valide le bug ; l'attendu se calcule à la main, avant.
Je vérifie que mon nouveau test échoue avant d'écrire le code.
sinon, il ne teste peut-être rien.
Pour faire passer le test, je remplace l'attendu par la valeur obtenue.
tu effaces le signal d'alarme au lieu de corriger.
J'ajoute un test pour la parcelle sans mesure.
les cas limites sont ceux qu'on oublie.
Une fois au vert, je renomme une variable et je relance tous les tests.
c'est l'étape « propre », sous protection.
[5]Quiz

1. Pourquoi l'étape « rouge » doit-elle échouer « pour la bonne raison » ?

un échec pour une autre raison (faute de frappe dans le test) ne prouve rien.

2. Avec les mesures (1 ; 8,0), (2 ; 14,5), (4 ; 31,5), quelle croissance hebdomadaire attend-on ?

17 mm en 2 semaines font 8,5 mm par semaine.

3. Que fait python -m pytest -k croissance ?

-k filtre les tests par leur nom (4 deselected : quatre tests non lancés).
[6]Kit

🎒 Ton kit — Épisode 14

30 min

Exercice : la croissance, test d'abord

  1. Écris le test de croissance_hebdo avec le cas calculé à la main ; lance-le : rouge (AttributeError).
  2. Écris la version naïve : encore rouge, et lis le tableau « Obtained / Expected ».
  3. Corrige : vert. Relance tous les tests.
  4. Recommence pour une nouvelle méthode, Parcelle.hauteur_semaine(semaine), qui renvoie la hauteur d'une semaine ou lève une ValueError si elle n'a pas été mesurée.

Code à copier : le test et la méthode croissance_hebdo.

Checklist

  • J'ai vu mon test échouer pour la bonne raison.
  • Je l'ai fait passer avec le minimum de code.
  • Tous les tests sont au vert après le nettoyage.
Vigilancene modifie jamais l'attendu d'un test pour le faire passer, sauf si tu as prouvé, à la main, que c'est l'attendu qui était faux.
[7]Journal

✍️ Journal de bord

Quelle question as-tu dû trancher en écrivant le test de hauteur_semaine avant de coder ? L'aurais-tu vue en codant directement ?

[8]Sources
  1. pytest, How to invoke pytest (option -k) et Get Started, version 9.2.0, consulté le 30/10/2026.
  2. Programmes exécutés le 30/10/2026 sous Python 3.12.13, 3.15.0 et 3.15.2 avec pytest 9.2.0 (journaux de test de ce livre, sections « E14 »).
Semaine 8 · Les annotations de type
15
S8 · MARDI · Comprendre

Dire ce qu'on attend

Annoter paramètres, retours et attributs ; list[float], dict[str, …], X | None ; faire vérifier le code par mypy avant de le lancer.

⏱ 30 min de lecture · 5 min de quiz
[1]Scénario
Yanis · Mardi, 10 h

Awa branche sa propre lecture sur le modèle de Yanis. Tout marche… jusqu'au jour où elle crée une mesure avec la hauteur "8,0" (du texte). Aucune erreur à la création. Le plantage arrive bien plus loin, dans un calcul, avec un message qui ne parle ni de mesure ni de hauteur. Yanis : « Il faudrait que l'erreur se voie au moment où on l'écrit, pas trois fonctions plus loin. »

[2]Concept

Les annotations de type

Une annotation de type indique le type attendu d'un paramètre, d'une valeur renvoyée ou d'une variable. Python ne la vérifie pas à l'exécution ; un vérificateur de types comme mypy lit le code, sans le lancer, et signale les incohérences.

Sans annotations, l'erreur arrive tard.

Code
def moyenne(valeurs):
    return sum(valeurs) / len(valeurs)


print(moyenne([8.0, 14.5]))
print(moyenne("8,0"))

Affiche 11.25, puis s'arrête sur TypeError: unsupported operand type(s) for +: 'int' and 'str' (« types non pris en charge pour + : int et str »), dans sum, loin de l'appel fautif. Et mypy ne dit rien : Success: no issues found in 1 source file, car une fonction sans annotations n'est pas vérifiée.

Avec annotations, mypy l'attrape avant.

Code
def moyenne(valeurs: list[float]) -> float:
    """Renvoie la moyenne d'une liste non vide."""
    return sum(valeurs) / len(valeurs)


hauteurs: dict[str, list[float]] = {"A": [8.0, 14.5], "B": [7.5]}
print(moyenne(hauteurs["A"]))
print(moyenne("8,0"))

python -m mypy e15_apres.py (sans lancer le programme) :

Rapport de mypy
e15_apres.py:8: error: Argument 1 to "moyenne" has incompatible type "str"; expected "list[float]" [arg-type] Found 1 error in 1 file (checked 1 source file)

« L'argument 1 de moyenne a un type incompatible : str ; attendu : list[float] ».

AnnotationSignification
def f(x: int) -> str:x est un entier ; la fonction renvoie un texte
-> Nonela fonction ne renvoie rien d'utile
list[float]une liste de nombres à virgule
dict[str, list[float]]un dictionnaire : clés textes, valeurs listes de nombres
tuple[list[Mesure], list[str]]un tuple de deux listes (lire_cahier, épisode 10)
float | Noneun nombre à virgule, ou None (Python 3.10 et plus)
hauteur: float dans une dataclassle type d'un champ (épisode 11)

Sur « Pousse ». Les modules sont annotés depuis l'épisode 10. L'erreur d'Awa, dans un petit script placé dans le dossier du projet :

Code
from analyse import croissance_moyenne
from modele import Mesure, Parcelle

parcelle = Parcelle("A", paillee=True)
parcelle.ajouter(Mesure(1, "2026-03-30", "A", "8,0"))
print(parcelle.mesures)
print(round(croissance_moyenne(parcelle), 1))

À l'exécution :

Ce que tu vois
[Mesure(semaine=1, date='2026-03-30', parcelle='A', hauteur='8,0')] Traceback (most recent call last): File "e15_types.py", line 7, in <module> print(round(croissance_moyenne(parcelle), 1)) TypeError: type NoneType doesn't define __round__ method

La hauteur '8,0' est entrée sans erreur ; le plantage vient plus tard, avec un message sur NoneType et __round__. mypy, lui, voit les deux problèmes avant :

Rapport de mypy
e15_types.py:5: error: Argument 4 to "Mesure" has incompatible type "str"; expected "float" [arg-type] e15_types.py:7: error: Argument 1 to "round" has incompatible type "float | None"; expected "_SupportsRound2[float]" [arg-type] Found 2 errors in 1 file (checked 1 source file)
  • Ligne 5 : la hauteur doit être un float, pas un str.
  • Ligne 7 : croissance_moyenne peut renvoyer None (avec moins de deux mesures) ; round ne sait pas arrondir None. Le libellé _SupportsRound2[float] désigne, en jargon de mypy, « quelque chose qu'on peut arrondir ». La correction : tester None avant d'arrondir.

Installer et lancer : dans l'environnement du projet, python -m pip install mypy, puis python -m mypy lecture.py modele.py analyse.py. On annote d'abord les fonctions que d'autres appellent ; une fonction non annotée n'est pas vérifiée par défaut.

[3]Recherche

Ce que disent les sources

  • Documentation de mypy — mypy est un vérificateur de types statique : il trouve des bugs sans exécuter le programme ; les annotations ne changent pas la façon dont le programme s'exécute ; par défaut, les fonctions sans annotations sont considérées comme dynamiques et ne sont pas vérifiées ; mypy s'installe avec python3 -m pip install mypy et demande Python 3.10 ou plus [1].
  • PEP 604 (Python 3.10) — X | Y est une écriture courte de l'union de deux types ; int | None est équivalent à Optional[int] [2].
  • Tests de ce livre — mypy 2.4.1, installé depuis PyPI le 30/10/2026, signale les deux erreurs de e15_types.py et ne trouve aucun problème dans les modules de « Pousse », sous Python 3.12.13, 3.15.0 et 3.15.2 (journaux de test).
[4]Mythes
« Les annotations rendent Python plus lent. »
elles ne changent pas l'exécution ; elles servent aux outils et aux lecteurs.
« Avec des annotations, Python refuse les mauvais types. »
Python accepte "8,0" comme hauteur ; c'est mypy, lancé à part, qui le signale.
« Il faut tout annoter d'un coup. »
on commence par les fonctions publiques d'un module ; mypy ignore les fonctions non annotées.
[5]Quiz

1. Que se passe-t-il à l'exécution de moyenne("8,0") si moyenne est annotée list[float] ?

les annotations ne sont pas vérifiées à l'exécution ; c'est le rôle de mypy.

2. Que signifie -> float | None ?

| se lit « ou » (Python 3.10 et plus).

3. Pourquoi mypy ne signale-t-il rien dans e15_avant.py ?

sans annotation, la fonction est considérée comme dynamique (sortie réelle : Success).
[6]Kit

🎒 Ton kit — Épisode 15

25 min

Exercice : faire vérifier « Pousse »

  1. Installe mypy dans l'environnement du projet.
  2. Lance-le sur e15_avant.py, puis sur e15_apres.py : compare.
  3. Lance-le sur les modules de « Pousse » : Success.
  4. Ajoute le script d'Awa, lis les deux erreurs, et corrige-le (hauteur en float, test de None avant round) jusqu'au Success.

Code à copier : e15_apres.py et e15_types.py.

Checklist

  • Mes fonctions publiques sont annotées.
  • Je sais lire un message de mypy (fichier, ligne, type obtenu, type attendu).
  • « Pousse » passe mypy sans erreur.
Vigilanceune valeur annotée X | None doit être testée (if valeur is None:) avant d'être utilisée comme un X.
[7]Journal

✍️ Journal de bord

Laquelle des deux erreurs du script d'Awa aurait été la plus longue à trouver sans mypy ? Pourquoi ?

[8]Sources
  1. Mypy, mypy documentation (« Getting started », « Dynamically typed code »), mypy 2.1, consulté le 30/10/2026 ; version installée et testée : mypy 2.4.1.
  2. M. Prados et P. Moisset, PEP 604 – Allow writing union types as X | Y (Python 3.10), consulté le 30/10/2026.
  3. Programmes exécutés le 30/10/2026 sous Python 3.12.13, 3.15.0 et 3.15.2, mypy 2.4.1 (journaux de test de ce livre).
16
S8 · JEUDI · Pratiquer · Bilan d'arc

Du code robuste : le bilan

Ton outil de synthèse, le récapitulatif de l'arc et ton badge.

⏱ 30 min · arbre de décision · carrousel · quiz final
[1]Scénario
Yanis · Jeudi, 18 h

Awa joue les saboteuses : elle ajoute au cahier une semaine « 7b », crée une parcelle sans mesure, appelle croissance_moyenne sur une parcelle d'une seule mesure et passe du texte à la place d'une hauteur. Rien ne plante sans explication : la ligne « 7b » est signalée, la parcelle vide renvoie None, pytest reste au vert, et mypy montre du doigt le texte. Yanis respire : le cœur de « Pousse » tient debout.

[2]Outil

« Exception, test ou annotation ? »

Aide à la décision interactive
[3]Carrousel

🎨 Carrousel récapitulatif — Arc 2

[4]Quiz final

1. Où placer try dans une boucle qui lit 300 lignes ?

une ligne invalide est écartée, les autres continuent d'être lues.

2. Quelle dataclass est correcte ?

une valeur par défaut modifiable est refusée ; default_factory crée une liste par objet.

3. Un test écrit avant le code passe immédiatement. Que penser ?

à l'étape « rouge », il doit échouer pour la bonne raison.

4. mypy signale Argument 4 to "Mesure" has incompatible type "str"; expected "float". Que fais-tu ?

l'annotation a fait son travail ; c'est l'appel qu'il faut corriger.

5. Quelle fonction lève une exception plutôt que de l'intercepter ?

on lève là où l'on détecte, on attrape là où l'on sait quoi faire.
[5]Kit

🎒 Ton kit de fin d'arc

30 min

Le cœur de « Pousse »

Synthèse : trois modules (lecture.py, épisode 10 ; modele.py, épisodes 12 et 14 ; analyse.py), un programme rapport.py, trois fichiers de tests.

analyse.py :

Code
"""Analyse du jardin : résumé de chaque parcelle et comparaison."""

import statistics

from modele import Jardin, Parcelle


def croissance_moyenne(parcelle: Parcelle) -> float | None:
    """Renvoie la croissance moyenne en mm par semaine, ou None avec moins de deux mesures."""
    croissances = parcelle.croissance_hebdo()
    if len(croissances) == 0:
        return None
    return round(statistics.mean(croissances), 2)


def comparer(jardin: Jardin) -> list[str]:
    """Renvoie une ligne de résumé par parcelle."""
    lignes = []
    for nom, parcelle in jardin.parcelles.items():
        etat = "nue"
        if parcelle.paillee:
            etat = "paillée"
        lignes.append(f"Parcelle {nom} ({etat}) : {len(parcelle.mesures)} mesures, "
                      f"hauteur finale {parcelle.hauteur_finale()} mm, "
                      f"croissance moyenne {croissance_moyenne(parcelle)} mm/semaine")
    return lignes

rapport.py :

Code
"""Lit le cahier du jardin et affiche la comparaison des parcelles."""

from pathlib import Path

from analyse import comparer
from lecture import lire_cahier
from modele import Jardin

DOSSIER = Path(__file__).parent
mesures, ecartees = lire_cahier(DOSSIER / "mesures-jardin.csv")
jardin = Jardin()
jardin.ajouter_parcelle("A", paillee=True)
jardin.ajouter_parcelle("B", paillee=False)
for mesure in mesures:
    jardin.ajouter_mesure(mesure)
for ligne in comparer(jardin):
    print(ligne)
print(f"{len(ecartees)} lignes écartées :")
for ligne in ecartees:
    print("  " + ligne)

Sortie réelle :

Ce que tu vois
Parcelle A (paillée) : 11 mesures, hauteur finale 74.5 mm, croissance moyenne 6.0 mm/semaine Parcelle B (nue) : 11 mesures, hauteur finale 59.5 mm, croissance moyenne 4.75 mm/semaine 2 lignes écartées : ligne 14 : hauteur absente : 'n.m.' ligne 17 : hauteur absente : ''

La parcelle paillée pousse en moyenne de 6 mm par semaine, la nue de 4,75 : sur ces données inventées, le paillage semble aider ; avec deux parcelles et douze semaines, ce n'est pas une preuve, et le README le dira (épisode 27).

test_analyse.py :

Code
from pathlib import Path

from analyse import comparer, croissance_moyenne
from lecture import lire_cahier
from modele import Jardin, Mesure, Parcelle


def test_croissance_moyenne_d_une_seule_mesure():
    parcelle = Parcelle("A", paillee=True)
    parcelle.ajouter(Mesure(1, "2026-03-30", "A", 8.0))
    assert croissance_moyenne(parcelle) is None


def test_cahier_du_jardin():
    mesures, ecartees = lire_cahier(Path(__file__).parent / "mesures-jardin.csv")
    jardin = Jardin()
    jardin.ajouter_parcelle("A", paillee=True)
    jardin.ajouter_parcelle("B", paillee=False)
    for mesure in mesures:
        jardin.ajouter_mesure(mesure)
    assert len(ecartees) == 2
    assert croissance_moyenne(jardin.parcelles["A"]) == 6.0
    assert croissance_moyenne(jardin.parcelles["B"]) == 4.75
    assert len(comparer(jardin)) == 2

Tous les tests (python -m pytest -v, sortie réelle ; durée et chemins varient) :

Rapport de pytest
============================= test session starts ============================== platform linux -- Python 3.15.0, pytest-9.2.0, pluggy-1.7.0 -- .venv/bin/python rootdir: …/pousse collecting ... collected 13 items test_analyse.py::test_croissance_moyenne_d_une_seule_mesure PASSED [ 7%] test_analyse.py::test_cahier_du_jardin PASSED [ 15%] test_lecture.py::test_virgule_et_point PASSED [ 23%] test_lecture.py::test_hauteur_absente[] PASSED [ 30%] test_lecture.py::test_hauteur_absente[n.m.] PASSED [ 38%] test_lecture.py::test_hauteur_absente[ ] PASSED [ 46%] test_lecture.py::test_hauteur_illisible PASSED [ 53%] test_lecture.py::test_cahier_avec_lignes_ecartees PASSED [ 61%] test_modele.py::test_parcelle_vide PASSED [ 69%] test_modele.py::test_hauteur_finale_quel_que_soit_l_ordre PASSED [ 76%] test_modele.py::test_croissance_avec_semaine_manquante PASSED [ 84%] test_modele.py::test_mesure_d_une_autre_parcelle PASSED [ 92%] test_modele.py::test_parcelle_inconnue PASSED [100%] ============================== 13 passed in 0.05s ==============================

mypy (python -m mypy lecture.py modele.py analyse.py rapport.py) :

Rapport de mypy
Success: no issues found in 4 source files

Consigne : rassemble dans le dossier pousse les trois modules, rapport.py, les trois fichiers de tests et une copie de mesures-jardin.csv ; ajoute pytest et mypy à requirements.txt (Pro, épisode 14) ; fais un commit « Cœur de Pousse : lecture, modèle, analyse, tests » (Pro, épisode 18). Puis exporte ton carnet de bord.

Checklist

  • J'ai exporté mon carnet (bouton dans le menu).
  • python -m pytest : 13 tests au vert.
  • python -m mypy : aucun problème.
[6]Journal

✍️ Journal de bord — bilan

Parmi les saboteries d'Awa, laquelle t'aurait fait le plus peur il y a un mois ? Quel outil de l'arc l'a désamorcée ?

✦ INGÉNIEURE · INGÉNIEUR DE LA FIABILITÉ · ARC 2 ✦

Bravo ! Tu as terminé l'arc 2. Tu sais gérer les exceptions sans rien cacher, modéliser tes données avec des classes, écrire des tests automatisés et faire vérifier tes types par un outil.

ARC 3 · SEMAINES 9 À 12

Construire le projet

« Pousse » devient un vrai logiciel : un paquet installable, la météo demandée à un serveur, toutes les données dans une base, et une petite application web qui tourne sur ton ordinateur. Fin d'arc : quelqu'un d'autre l'installe et le lance en cinq commandes.

Semaine 9 · Le paquet
17
S9 · MARDI · Comprendre

Un projet en paquet

Modules, paquet et __init__.py, imports absolus, __main__.py et if __name__ == "__main__", disposition src/, pyproject.toml et installation modifiable.

⏱ 30 min de lecture · 5 min de quiz
[1]Scénario
Yanis · Mardi, 19 h

Le dossier de « Pousse » compte maintenant dix fichiers à plat : modules, tests, essais, données. Yanis range les tests dans un sous-dossier tests… et pytest ne trouve plus modele : ModuleNotFoundError. Il les remet à côté : ça marche. Il lance un essai depuis un autre dossier : ça ne marche plus. « Pourquoi mes imports marchent ici et pas là ? »

[2]Concept

Modules, paquets et pyproject.toml

Un module est un fichier .py ; un paquet est un dossier de modules qui contient un fichier __init__.py. Quand Python lit import pousse.modele, il cherche un paquet pousse dans les dossiers de son chemin d'import : le dossier du script lancé, puis les paquets installés dans l'environnement. D'où le mystère de Yanis : ses imports dépendaient du dossier d'où il lançait le programme.

La solution : un paquet installé. La disposition recommandée range le code dans src/ :

Le dossier du projet
pousse/ ├── pyproject.toml la carte d'identité du projet ├── donnees/ │ └── mesures-jardin.csv ├── src/ │ └── pousse/ le paquet │ ├── __init__.py │ ├── __main__.py lancé par « python -m pousse » │ ├── lecture.py │ ├── modele.py │ └── analyse.py └── tests/ ├── test_lecture.py ├── test_modele.py └── test_analyse.py

pyproject.toml, au format TOML (des sections entre crochets, des lignes clé = valeur) :

Fichier à copier (TOML)
[build-system]
requires = ["setuptools >= 77.0.3"]
build-backend = "setuptools.build_meta"

[project]
name = "pousse"
version = "0.1.0"
description = "Analyse du cahier de mesures d'un jardin partagé, croisé avec la météo."
requires-python = ">= 3.12"
dependencies = ["requests", "flask", "matplotlib"]

[project.optional-dependencies]
dev = ["pytest", "mypy"]

[tool.setuptools.package-data]
pousse = ["templates/*.html"]
  • [build-system] dit quel outil sait construire le paquet (ici setuptools) ; pip l'installe tout seul.
  • [project] : le nom, la version, la version minimale de Python, et les dépendances (installées automatiquement avec le paquet, comme dans requirements.txt, Pro, épisode 14). Les paquets requests, flask et matplotlib serviront aux épisodes 19 et 23.
  • [project.optional-dependencies] : des dépendances en option, ici les outils de développement : python -m pip install -e ".[dev]" les ajoute.
  • [tool.setuptools.package-data] : les fichiers non Python à emporter avec le paquet (les pages HTML de l'épisode 23).

__init__.py marque le dossier comme paquet ; il peut ne contenir qu'une docstring :

Code
"""Pousse : analyse du cahier de mesures d'un jardin partagé, croisé avec la météo."""

Imports absolus. Dans les modules, on écrit le chemin complet depuis le paquet : from pousse.modele import Mesure (et non plus from modele import Mesure). Les tests aussi : from pousse.lecture import lire_taille.

__main__.py et if __name__ == "__main__" :

Code
"""Point d'entrée : python -m pousse [chemin du cahier]."""

import sys
from pathlib import Path

from pousse.analyse import comparer
from pousse.lecture import lire_cahier
from pousse.modele import Jardin


def main() -> None:
    """Lit le cahier, range les mesures dans le jardin et affiche la comparaison."""
    chemin = Path("donnees/mesures-jardin.csv")
    if len(sys.argv) > 1:
        chemin = Path(sys.argv[1])
    mesures, ecartees = lire_cahier(chemin)
    jardin = Jardin()
    jardin.ajouter_parcelle("A", paillee=True)
    jardin.ajouter_parcelle("B", paillee=False)
    for mesure in mesures:
        jardin.ajouter_mesure(mesure)
    for ligne in comparer(jardin):
        print(ligne)
    print(f"{len(ecartees)} lignes écartées.")


if __name__ == "__main__":
    main()
  • python -m pousse exécute le fichier __main__.py du paquet.
  • sys.argv est la liste des mots de la commande : sys.argv[1] est le premier argument donné après le nom du programme.
  • __name__ vaut "__main__" quand le fichier est lancé, et le nom du module quand il est importé. Le bloc if __name__ == "__main__": ne s'exécute donc qu'au lancement : on peut importer main (pour la tester) sans lancer le programme. C'est le cas laissé en suspens au Pro.

Installer en mode modifiable. Dans l'environnement du projet, depuis le dossier qui contient pyproject.toml :

Dans le terminal
$ python -m pip install -e .

Avant cette commande, les tests ne trouvent pas le paquet :

Rapport de pytest
E ModuleNotFoundError: No module named 'pousse' !!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!! 1 error in 0.10s

Après (dernière ligne de la sortie de pip) :

Ce que tu vois
Successfully installed pousse-0.1.0

Puis python -m pousse, depuis le dossier du projet :

Ce que tu vois
Parcelle A (paillée) : 11 mesures, hauteur finale 74.5 mm, croissance moyenne 6.0 mm/semaine Parcelle B (nue) : 11 mesures, hauteur finale 59.5 mm, croissance moyenne 4.75 mm/semaine 2 lignes écartées.

Avec -e (editable, modifiable), pip n'a pas copié le code : il a ajouté src/ au chemin d'import de l'environnement. Tes modifications sont prises en compte sans réinstaller ; il ne faut réinstaller que si tu changes pyproject.toml (une dépendance, la version).

Les gestionnaires récents. Des outils comme uv ou Poetry gèrent environnement, dépendances et pyproject.toml en une commande ; ce livre s'en tient à venv et pip, fournis avec Python.

Remets les lignes dans l'ordre · clique les lignes une par une
Ton programme
Lignes à placer
[3]Recherche

Ce que disent les sources

  • Python Packaging User Guide, « Writing your pyproject.toml » — Le fichier peut contenir les tables [build-system] (qui « devrait toujours être présente »), [project] et [tool] ; name est obligatoire, version aussi (ou calculée) ; requires-python, dependencies et optional-dependencies décrivent les besoins ; l'exemple pour setuptools demande setuptools >= 77.0.3 [1].
  • Python Packaging User Guide, « src layout vs flat layout » — La disposition src évite d'utiliser par accident la copie en cours de développement à la place de la copie installée, limite l'installation modifiable aux fichiers voulus, et fait tester le paquet installé [2].
  • Documentation officielle de __main__ — __name__ vaut '__main__' quand le module est le programme principal ; il est recommandé de garder ce bloc court et de mettre le comportement dans une fonction main ; le fichier __main__.py d'un paquet s'exécute avec python -m paquet [3].
  • Documentation de pip, « Local project installs » — Une installation modifiable ne copie pas les fichiers : le dossier de développement est ajouté au chemin d'import ; il ne faut réinstaller que si les métadonnées du projet changent [4].
[4]Mythes
« pyproject.toml, c'est seulement pour publier sur PyPI. »
il décrit le projet pour tous les outils : pip, pytest, et ton binôme qui installe tout en une commande.
« __init__.py doit contenir du code. »
il peut ne contenir qu'une docstring ; sa présence suffit à faire du dossier un paquet.
« Mes imports marchent sur mon ordinateur, donc ils marchent partout. »
ils marchaient depuis un dossier précis ; un paquet installé marche depuis n'importe où.
[5]Quiz

1. Que vaut __name__ dans analyse.py quand __main__.py l'importe ?

seul le module lancé s'appelle "__main__" ; un module importé porte son nom.

2. Après pip install -e ., tu modifies analyse.py. Que faut-il faire ?

en mode modifiable, src/ est sur le chemin d'import ; on réinstalle seulement si pyproject.toml change.

3. Que fait python -m pousse ?

-m lance un module ou un paquet ; pour un paquet, c'est son __main__.py.
[6]Kit

🎒 Ton kit — Épisode 17

30 min

Exercice : « Pousse » devient un paquet

  1. Crée l'arborescence src/pousse/ et tests/, et déplace tes modules et tes tests.
  2. Remplace les imports par des imports absolus (from pousse.…).
  3. Écris pyproject.toml, __init__.py et __main__.py.
  4. Dans l'environnement du projet : python -m pip install -e ".[dev]", puis python -m pytest et python -m pousse.

Code à copier : pyproject.toml, __init__.py et __main__.py.

Checklist

  • Mon code est dans src/pousse/, mes tests dans tests/.
  • Tous mes imports commencent par pousse..
  • python -m pousse et python -m pytest marchent depuis le dossier du projet.
Vigilanceinstalle le paquet dans l'environnement virtuel du projet, jamais dans le Python du système (Pro, épisode 14).
[7]Journal

✍️ Journal de bord

Quelle erreur d'import t'a déjà fait perdre du temps ? Explique-la maintenant avec le chemin d'import.

[8]Sources
  1. Python Packaging Authority, Python Packaging User Guide — « Writing your pyproject.toml », consulté le 30/10/2026.
  2. Python Packaging Authority, Python Packaging User Guide — « src layout vs flat layout », consulté le 30/10/2026.
  3. Python Software Foundation, __main__ — Top-level code environment, consulté le 30/10/2026.
  4. Python Packaging Authority, pip documentation — « Local project installs », consulté le 30/10/2026.
  5. Commandes exécutées le 30/10/2026 sous Python 3.12.13, 3.15.0 et 3.15.2 (journaux de test de ce livre).
18
S9 · JEUDI · Pratiquer

Ranger « Pousse »

Déplacer, réinstaller, retester : restructurer un projet sans rien casser, un commit par étape.

⏱ 30 min de pratique · 5 min de quiz
[1]Scénario
Yanis · Jeudi, 14 h

Yanis a tout déplacé d'un coup : modules, tests, imports, pyproject.toml. Douze erreurs. Il ne sait plus laquelle vient de quoi. Awa : « Annule. Un fichier à la fois, et les tests entre chaque. Tu as Git pour ça. »

[2]Méthode

« Déplacer, réinstaller, retester »

  1. Partir du vert : python -m pytest passe avant de commencer ; commit (Pro, épisode 18).
  2. Créer la structure vide : src/pousse/__init__.py, tests/, pyproject.toml ; python -m pip install -e . ; commit.
  3. Déplacer un module à la fois, avec ses imports devenus absolus, et le test qui le concerne ; relancer python -m pytest ; commit.
  4. Ajouter le point d'entrée __main__.py ; vérifier python -m pousse ; commit.
  5. Vérifier depuis un dossier neuf : un clone, un environnement neuf, pip install -e ".[dev]", pytest (Pro, épisode 18).

Résultat attendu après la dernière étape (python -m pytest, sortie réelle) :

Rapport de pytest
........................ [100%] 24 passed in 1.01s

24 tests : ceux de l'arc 2, et ceux des modules des épisodes 19 à 23. Après chaque déplacement, seuls les tests du module déplacé peuvent changer : s'ils deviennent rouges, l'erreur est dans le déplacement qu'on vient de faire.

Auto-diagnostic · mon projet est-il bien structuré ?
  • Mon code est dans src/pousse/, avec un __init__.py.
  • Mes tests sont dans tests/.
  • Tous mes imports commencent par pousse..
  • pyproject.toml donne le nom, la version, le Python requis et les dépendances.
  • Après pip install -e ., tous les tests passent.
  • python -m pousse marche depuis le dossier du projet.
[3]Pourquoi ça marche

Une restructuration ne change pas ce que fait le code : les tests doivent rester verts du début à la fin. En avançant d'un module à la fois, chaque rouge pointe vers la dernière modification ; en committant à chaque étape, on peut toujours revenir au dernier état vert. Le test du dossier neuf vérifie ce que ton ordinateur cache : un fichier oublié, une dépendance absente de pyproject.toml.

À retenirpartir du vert, un déplacement à la fois, les tests entre chaque, un commit par étape, puis un clone neuf.
[4]Entraînement

Bon réflexe ou piège ?

Je déplace tous les fichiers d'un coup, puis je corrige les erreurs.
tu ne sais plus quelle erreur vient de quel déplacement.
Je relance les tests après chaque module déplacé.
un rouge désigne le dernier déplacement.
J'ajoute sys.path.append("..") dans mes tests pour qu'ils trouvent les modules.
c'est un bricolage qui dépend du dossier courant ; installe le paquet.
J'oublie d'ajouter requests dans les dépendances : il est déjà installé chez moi.
chez ton binôme, l'installation ne l'apportera pas ; le clone neuf le révélera.
Je fais un commit à chaque étape verte.
tu peux revenir au dernier état qui marchait.
[5]Quiz

1. Après avoir déplacé lecture.py dans src/pousse/, test_lecture.py échoue sur ModuleNotFoundError: No module named 'lecture'. Que corriger ?

le module fait maintenant partie du paquet pousse.

2. Pourquoi vérifier depuis un clone et un environnement neufs ?

c'est la situation de toute personne qui installera ton projet.

3. Combien de tests passent à la fin de l'arc 3 ?

les 13 de l'arc 2 et 11 nouveaux (météo, base, application) (sortie réelle).
[6]Kit

🎒 Ton kit — Épisode 18

30 min

Exercice : la restructuration de « Pousse »

  1. Applique la méthode, module par module, avec un commit par étape verte.
  2. Fais le test du dossier neuf : clone, environnement, pip install -e ".[dev]", pytest, python -m pousse.
  3. Coche l'auto-diagnostic.

Code à copier : aucun ; ce sont des déplacements et des commandes.

Checklist

  • Mes tests sont restés verts à chaque étape.
  • J'ai un commit par étape.
  • Le projet s'installe et passe ses tests depuis un clone neuf.
Vigilancene supprime pas l'ancien dossier avant que le nouveau passe tous les tests ; Git garde l'historique, mais une copie de sauvegarde ne coûte rien.
[7]Journal

✍️ Journal de bord

Le test du clone neuf a-t-il révélé un oubli ? Lequel, et comment l'aurais-tu découvert autrement ?

[8]Sources
  1. Python Packaging Authority, Python Packaging User Guide — « src layout vs flat layout », consulté le 30/10/2026.
  2. Commandes exécutées le 30/10/2026 sous Python 3.12.13, 3.15.0 et 3.15.2 (journaux de test de ce livre).
Semaine 10 · L'API météo
19
S10 · MARDI · Comprendre

Demander des données à un serveur

Client et serveur, requête GET et paramètres, codes de statut, réponse JSON, délai et erreurs réseau, conditions d'usage d'une API.

⏱ 30 min de lecture · 5 min de quiz
[1]Scénario
Yanis · Mardi, 11 h

La température a sûrement joué sur la croissance des radis. Yanis trouve un site météo, copie à la main une semaine de températures dans un tableur… puis découvre qu'un serveur peut lui envoyer les 84 jours de la saison en une seule requête, au format JSON, sans compte ni clé.

[2]Concept

Les API web

Une API web (interface de programmation) est un service qu'un programme interroge par Internet. Ton programme est le client ; il envoie une requête HTTP à un serveur, qui renvoie une réponse : un code de statut et un contenu, souvent en JSON (Pro, épisode 13).

L'API météo de « Pousse » : Open-Meteo. Son archive historique répond à l'adresse https://archive-api.open-meteo.com/v1/archive. Une requête GET lui passe des paramètres après un ? : latitude, longitude, start_date, end_date (au format AAAA-MM-JJ), les variables quotidiennes voulues (daily) et le fuseau horaire (timezone, obligatoire pour des données quotidiennes). La réponse est un dictionnaire JSON ; sa partie daily contient une liste time (les jours) et une liste par variable, dans le même ordre ; daily_units donne les unités.

Le module meteo.py de « Pousse » utilise la bibliothèque requests (une dépendance de pyproject.toml, épisode 17) :

Code
"""Météo du jardin : appel de l'API Open-Meteo (archive historique), cache dans un fichier, lecture."""

import json
from pathlib import Path

import requests

URL_ARCHIVE = "https://archive-api.open-meteo.com/v1/archive"
LATITUDE, LONGITUDE = 45.76, 4.84  # le centre de Lyon : un lieu public, jamais une adresse personnelle


def telecharger_meteo(debut: str, fin: str, url: str = URL_ARCHIVE) -> dict:
    """Demande à l'API la température moyenne et la pluie de chaque jour entre debut et fin (AAAA-MM-JJ)."""
    parametres: dict[str, str | float] = {
        "latitude": LATITUDE,
        "longitude": LONGITUDE,
        "start_date": debut,
        "end_date": fin,
        "daily": "temperature_2m_mean,precipitation_sum",
        "timezone": "Europe/Paris",
    }
    reponse = requests.get(url, params=parametres, timeout=10)
    reponse.raise_for_status()
    return reponse.json()


def enregistrer_cache(donnees: dict, chemin: Path) -> None:
    """Enregistre la réponse de l'API dans un fichier JSON."""
    with open(chemin, "w", encoding="utf-8") as fichier:
        json.dump(donnees, fichier, ensure_ascii=False, indent=1)


def lire_cache(chemin: Path) -> dict:
    """Relit une réponse enregistrée."""
    with open(chemin, encoding="utf-8") as fichier:
        return json.load(fichier)


def temperatures(donnees: dict) -> dict[str, float]:
    """Renvoie {date: température moyenne} ; les jours sans valeur (None) sont ignorés."""
    jours = donnees["daily"]["time"]
    valeurs = donnees["daily"]["temperature_2m_mean"]
    return {jours[i]: valeurs[i] for i in range(len(jours)) if valeurs[i] is not None}


def obtenir_meteo(debut: str, fin: str, cache: Path, url: str = URL_ARCHIVE) -> dict:
    """Lit le cache s'il existe ; sinon appelle l'API une fois et enregistre la réponse."""
    if cache.exists():
        return lire_cache(cache)
    donnees = telecharger_meteo(debut, fin, url)
    enregistrer_cache(donnees, cache)
    return donnees
  • requests.get(url, params=…, timeout=10) construit l'adresse avec ses paramètres et envoie la requête ; timeout=10 abandonne au bout de 10 secondes sans réponse, au lieu d'attendre indéfiniment.
  • raise_for_status() lève une exception requests.HTTPError si le code de statut signale une erreur (épisode 9).
  • reponse.json() transforme le JSON reçu en dictionnaire Python.
  • temperatures ignore les jours sans valeur : l'API peut renvoyer null (devenu None).
  • Le lieu est une ville (le centre de Lyon), jamais une adresse personnelle : on n'envoie aucune donnée personnelle à un service extérieur.

Les codes de statut à connaître.

CodeSensCe que fait « Pousse »
200OK : la réponse contient les donnéeslit le JSON
400requête incorrecte (un paramètre faux)HTTPError ; l'API Open-Meteo explique la raison dans reason
404adresse inconnueHTTPError
429trop de requêtes : le serveur demande de ralentirHTTPError ; d'où le cache de l'épisode 20
500erreur du serveurHTTPError ; on réessaiera plus tard

Un essai. Le programme essai_meteo.py affiche les clés de la réponse, ses unités et les trois premiers jours, puis provoque deux erreurs : des dates inversées (400) et un serveur injoignable.

Code
"""Appelle l'API (ou le faux serveur local donné en argument) et affiche les premiers jours."""
import sys

import requests

from pousse.meteo import URL_ARCHIVE, telecharger_meteo, temperatures

url = URL_ARCHIVE
if len(sys.argv) > 1:
    url = sys.argv[1]
donnees = telecharger_meteo("2026-03-24", "2026-06-15", url)
print(sorted(donnees))
print(donnees["daily_units"])
temp = temperatures(donnees)
print(len(temp), "jours ;", list(temp.items())[:3])
try:
    telecharger_meteo("2026-06-15", "2026-03-24", url)
except requests.HTTPError as erreur:
    print("Erreur HTTP :", erreur.response.status_code, erreur.response.json()["reason"])
try:
    telecharger_meteo("2026-03-24", "2026-06-15", "http://127.0.0.1:9")
except requests.ConnectionError:
    print("Pas de réseau : la météo n'a pas pu être téléchargée.")

Sortie réelle, obtenue contre un serveur d'essai local qui renvoie une réponse au format documenté par Open-Meteo, avec des valeurs inventées (l'environnement de rédaction de ce livre ne peut pas joindre l'API) :

Ce que tu vois
['daily', 'daily_units', 'elevation', 'generationtime_ms', 'latitude', 'longitude', 'timezone', 'timezone_abbreviation', 'utc_offset_seconds'] {'time': 'iso8601', 'temperature_2m_mean': '°C', 'precipitation_sum': 'mm'} 83 jours ; [('2026-03-24', 6.9), ('2026-03-25', 8.8), ('2026-03-26', 9.5)] Erreur HTTP : 400 Parameter 'start_date' must be before 'end_date' Pas de réseau : la météo n'a pas pu être téléchargée.

requests.ConnectionError est levée quand le serveur ne répond pas du tout (pas de réseau, mauvaise adresse). Chez toi, lancé sans argument, le programme interroge la vraie API : les clés et les unités seront les mêmes, les températures seront les vraies.

Les conditions d'usage. Open-Meteo est gratuit, sans compte ni clé, pour un usage non commercial, dans la limite de 10 000 appels par jour ; ses données sont sous licence CC BY 4.0 : on peut les réutiliser en citant la source (« Météo : Open-Meteo.com », avec la référence demandée sur son site). « Pousse » respecte les deux : un seul appel par saison grâce au cache (épisode 20), et l'attribution sur chaque page de l'application (épisode 23).

Sans installation : urllib.request, dans la bibliothèque standard, sait aussi envoyer une requête ; requests est plus simple à lire et gère mieux les erreurs.

Simulateur de requête · API historique d'Open-Meteo
Variable :
Adresse de la requête
Réponse

Extrait de la réponse d'exemple du livre : format documenté par Open-Meteo, valeurs inventées. La vraie API répond avec les vraies valeurs.

[3]Recherche

Ce que disent les sources

  • Documentation de l'API historique d'Open-Meteo — Adresse https://archive-api.open-meteo.com/v1/archive ; paramètres obligatoires latitude, longitude, start_date, end_date ; timezone est requis dès qu'on demande des variables quotidiennes ; une erreur renvoie le code 400 et un objet JSON avec "error": true et une "reason" ; les données ERA5 arrivent avec 5 jours de décalage ; les usages commerciaux demandent une clé [1].
  • Page d'accueil d'Open-Meteo — Pas de clé, pas d'inscription, pas de carte bancaire pour un usage non commercial ; 10 000 appels par jour ; données sous licence CC BY 4.0, réutilisables en citant la source [2].
  • Documentation de la variable temperature_2m_mean — Elle figure parmi les variables quotidiennes documentées par Open-Meteo, en °C [3].
  • MDN Web Docs — 429 Too Many Requests est une erreur client : le client a envoyé trop de requêtes en un temps donné (limitation de débit) ; la réponse peut indiquer avec Retry-After le délai avant de réessayer [4].
  • requests 2.34.2 (version installée depuis PyPI le 30/10/2026) — Le paramètre timeout de requests.get est facultatif : c'est le nombre de secondes à attendre la réponse du serveur ; HTTPError, ConnectionError et Timeout dérivent de RequestException (documentation intégrée au code, lue le 30/10/2026) [5].
[4]Mythes
« Une API, c'est un site web. »
un site renvoie des pages à lire ; une API renvoie des données (JSON) à un programme.
« Si l'API répond une fois, elle répondra toujours. »
réseau coupé, serveur en panne, limite d'appels atteinte : le code doit prévoir l'échec, avec un délai et des exceptions.
« Des données gratuites sont libres de droits. »
celles d'Open-Meteo sont sous CC BY 4.0 : gratuites, mais avec l'obligation de citer la source.
[5]Quiz

1. Une API météo répond 400 et explique que la date de début doit précéder la date de fin. Que s'est-il passé ?

les codes 4xx désignent une erreur dans la requête du client.

2. Pourquoi timeout=10 ?

sans délai, le programme pourrait attendre très longtemps une réponse qui ne vient pas.

3. Que demande la licence CC BY 4.0 des données d'Open-Meteo ?

BY signifie « attribution » : on cite l'auteur des données.
[6]Kit

🎒 Ton kit — Épisode 19

30 min

Exercice : la météo de la saison

  1. Ajoute meteo.py au paquet et requests aux dépendances ; réinstalle (pip install -e ., puisque pyproject.toml a changé).
  2. Si ton réseau le permet, lance python essais/essai_meteo.py : la vraie API répond pour la ville du jardin. Note la température du premier jour.
  3. Sinon, ou en plus : lance le serveur d'essai fourni avec le livre (essais/serveur_essai.py) et passe son adresse au programme.
  4. Vérifie dans la documentation d'Open-Meteo le nom d'une autre variable quotidienne (la pluie, l'ensoleillement) et ajoute-la à la requête.

Code à copier : meteo.py et essai_meteo.py.

Checklist

  • Ma requête a un délai (timeout) et vérifie le code de statut.
  • Je sais distinguer une erreur 4xx d'une erreur 5xx et d'une absence de réseau.
  • Je cite la source des données météo.
Vigilancen'envoie jamais à une API une donnée personnelle (adresse, nom) ; ici, une ville suffit.
[7]Journal

✍️ Journal de bord

Dans ta discipline, quelles données publiques (météo, biodiversité, santé publique) aimerais-tu interroger par une API ? Quelles conditions d'usage faudrait-il vérifier ?

[8]Sources
  1. Open-Meteo, Historical Weather API, consulté le 30/10/2026.
  2. Open-Meteo, Free Weather API (page d'accueil), consulté le 30/10/2026.
  3. Open-Meteo, Weather Forecast API, table des variables quotidiennes, consulté le 30/10/2026.
  4. MDN Web Docs, 429 Too Many Requests, consulté le 30/10/2026.
  5. Python Software Foundation (projet requests), requests 2.34.2, documentation de requests.request et module requests.exceptions, installés depuis PyPI, consulté le 30/10/2026. (pypi.org/project/requests/)
  6. Programmes exécutés le 30/10/2026 sous Python 3.12.13, 3.15.0 et 3.15.2 contre un serveur d'essai local (réponse au format documenté, valeurs inventées) (journaux de test de ce livre).
20
S10 · JEUDI · Pratiquer

La météo du jardin, sans dépendre du réseau

Appeler une fois, enregistrer, lire partout : un cache dans un fichier, et des tests qui n'utilisent jamais le réseau.

⏱ 30 min de pratique · 5 min de quiz
[1]Scénario
Yanis · Jeudi, 8 h

Dans le train, sans réseau, Yanis lance ses tests : trois échouent, sur ConnectionError. Et la veille, en relançant son script vingt fois de suite, il a fait vingt appels identiques à l'API, pour des températures du printemps dernier qui ne changeront plus jamais.

[2]Méthode

« Appeler une fois, enregistrer, lire partout »

  1. Séparer l'appel réseau (telecharger_meteo) de la lecture des données (temperatures) : la lecture se teste sans réseau.
  2. Enregistrer la réponse dans un fichier cache (enregistrer_cache) et la relire ensuite (lire_cache).
  3. Une seule porte d'entrée, obtenir_meteo : si le cache existe, on le lit ; sinon, on appelle l'API une fois et on enregistre (le code est dans meteo.py, épisode 19).
  4. Tester sur une réponse enregistrée, jamais sur le réseau : un test qui dépend du réseau échoue dans le train, et il interroge l'API à chaque lancement.
  5. Prévoir l'échec : sans cache et sans réseau, un message clair (ConnectionError interceptée, épisode 9).

Les tests de meteo.py :

Code
from pathlib import Path

from pousse.meteo import enregistrer_cache, lire_cache, obtenir_meteo, temperatures

REPONSE = Path(__file__).parent / "reponse-exemple-meteo.json"


def test_temperatures_ignore_les_jours_sans_valeur():
    donnees = lire_cache(REPONSE)
    temp = temperatures(donnees)
    assert len(donnees["daily"]["time"]) == 84
    assert len(temp) == 83
    assert "2026-04-03" not in temp


def test_cache_aller_retour(tmp_path):
    donnees = {"daily": {"time": ["2026-04-06"], "temperature_2m_mean": [11.5]}}
    cache = tmp_path / "meteo.json"
    enregistrer_cache(donnees, cache)
    assert lire_cache(cache) == donnees


def test_obtenir_meteo_utilise_le_cache_sans_reseau(tmp_path):
    cache = tmp_path / "meteo.json"
    enregistrer_cache(lire_cache(REPONSE), cache)
    donnees = obtenir_meteo("2026-03-24", "2026-06-15", cache, url="http://adresse-invalide.example")
    assert donnees["timezone"] == "Europe/Paris"
  • Le premier lit la réponse d'exemple enregistrée dans tests/ : 84 jours, dont un sans température (le 3 avril, null dans l'exemple) ; il en reste 83.
  • Le troisième passe une adresse invalide : s'il appelait le réseau, il échouerait ; il passe, donc le cache a bien été lu.

Le cache, en vrai (essai_cache.py, avec le serveur d'essai local) :

Code
"""Premier appel : téléchargement et cache ; second appel : lecture du cache, même sans serveur."""
import sys
from pathlib import Path

from pousse.meteo import URL_ARCHIVE, obtenir_meteo, temperatures

url = URL_ARCHIVE
if len(sys.argv) > 1:
    url = sys.argv[1]
cache = Path("meteo-cache.json")
donnees = obtenir_meteo("2026-03-24", "2026-06-15", cache, url)
print("1er appel :", len(temperatures(donnees)), "jours ; cache créé :", cache.exists())
donnees = obtenir_meteo("2026-03-24", "2026-06-15", cache, url="http://127.0.0.1:9")
print("2e appel (serveur injoignable) :", len(temperatures(donnees)), "jours, lus dans le cache")
Ce que tu vois
1er appel : 83 jours ; cache créé : True 2e appel (serveur injoignable) : 83 jours, lus dans le cache

Au second appel, le serveur est injoignable : les données viennent du fichier. Pour rafraîchir le cache, on supprime le fichier.

Fichiers fournis · pour les tests et l'entraînement sans réseau

Lis le serveur d'essai avant de le lancer (règle d'or) : il n'écoute que ton ordinateur (127.0.0.1).

[3]Pourquoi ça marche

Des données du passé ne changent pas : les télécharger une fois suffit. Le cache rend le programme rapide, utilisable sans réseau et respectueux de l'API (une requête par saison au lieu de vingt par jour). Séparer l'appel de la lecture rend presque tout le code testable sans réseau : seul telecharger_meteo en a besoin, et il est trop simple pour cacher une logique compliquée.

À retenirune requête, un cache, une lecture ; des tests sur une réponse enregistrée ; un message clair quand le réseau manque.
[4]Entraînement

Bon réflexe ou piège ?

Mes tests appellent la vraie API : c'est plus réaliste.
ils échouent sans réseau, ralentissent et consomment des appels ; teste sur une réponse enregistrée.
J'enregistre la réponse de l'API dans un fichier et je la relis ensuite.
un seul appel pour des données qui ne changent pas.
Quand l'appel échoue, je renvoie un dictionnaire vide.
l'analyse continuerait sans météo, sans prévenir ; lève ou affiche un message clair.
Je lance mon script en boucle toutes les secondes pour “surveiller” l'API.
tu dépasses vite la limite d'appels (429) ; des données passées ne changent pas.
[5]Quiz

1. Pourquoi le troisième test passe-t-il une adresse invalide ?

si la fonction appelait le réseau, l'adresse invalide ferait échouer le test.

2. Combien de températures temperatures renvoie-t-elle pour la réponse d'exemple ?

un jour a une valeur None, ignorée (test et sortie réels).

3. Comment rafraîchir les données en cache ?

sans cache, obtenir_meteo rappelle l'API une fois.
[6]Kit

🎒 Ton kit — Épisode 20

30 min

Exercice : la météo, une fois pour toutes

  1. Ajoute à meteo.py les fonctions de cache et obtenir_meteo.
  2. Écris test_meteo.py et copie la réponse d'exemple du livre dans tests/.
  3. Lance tous les tests en coupant ton réseau (mode avion) : ils doivent passer.
  4. Lance essai_cache.py deux fois de suite et vérifie qu'un seul appel est fait.

Code à copier : test_meteo.py et essai_cache.py.

Checklist

  • L'appel réseau est séparé de la lecture.
  • Mes tests passent sans réseau.
  • Le programme affiche un message clair quand il n'y a ni cache ni réseau.
Vigilanceune réponse d'exemple aux valeurs inventées sert aux tests, jamais à l'analyse ; pour tes résultats, utilise la vraie réponse de l'API.
[7]Journal

✍️ Journal de bord

Quels autres appels de tes projets (fichiers téléchargés, calculs longs) mériteraient un cache ?

[8]Sources
  1. Open-Meteo, Free Weather API (limite de 10 000 appels par jour pour un usage non commercial), consulté le 30/10/2026.
  2. Programmes et tests exécutés le 30/10/2026 sous Python 3.12.13, 3.15.0 et 3.15.2, sans réseau, avec la réponse d'exemple du livre (journaux de test de ce livre).
Semaine 11 · La base de données
21
S11 · MARDI · Comprendre

Une base de données dans un fichier

Tables, lignes et clés ; le module sqlite3 ; CREATE TABLE, INSERT avec des paramètres ?, SELECT, WHERE, ORDER BY, GROUP BY, JOIN ; les transactions.

⏱ 30 min de lecture · 5 min de quiz
[1]Scénario
Yanis · Mardi, 17 h

Le CSV des mesures, le JSON de la météo, bientôt un fichier pour les nouvelles mesures saisies dans l'application… Pour répondre à « quelle hauteur après une semaine à plus de 15 °C ? », Yanis ouvre trois fichiers et écrit trois boucles imbriquées. Le doctorant hausse les épaules : « Mets tout dans une base. Une requête, et c'est fini. »

[2]Concept

SQLite et le langage SQL

Une base de données range des données dans des tables : des lignes (une mesure) et des colonnes (semaine, parcelle, hauteur). On l'interroge avec SQL, un langage de requêtes. SQLite est une base complète dans un seul fichier, sans serveur ; le module sqlite3 est fourni avec Python.

Code
"""Découverte de SQLite : créer une table, insérer, interroger."""
import sqlite3

connexion = sqlite3.connect(":memory:")
connexion.execute("CREATE TABLE mesures (semaine INTEGER, parcelle TEXT, hauteur REAL)")
releves = [(1, "A", 8.0), (1, "B", 7.5), (2, "A", 14.5), (2, "B", 12.0), (3, "A", 22.0), (3, "B", 18.5)]
with connexion:
    connexion.executemany("INSERT INTO mesures VALUES (?, ?, ?)", releves)

print(connexion.execute("SELECT * FROM mesures WHERE parcelle = ? ORDER BY hauteur DESC", ("A",)).fetchall())
print(connexion.execute("SELECT parcelle, COUNT(*), ROUND(AVG(hauteur), 2) FROM mesures GROUP BY parcelle").fetchall())
connexion.execute("CREATE TABLE parcelles (nom TEXT PRIMARY KEY, paillee INTEGER)")
with connexion:
    connexion.executemany("INSERT INTO parcelles VALUES (?, ?)", [("A", 1), ("B", 0)])
requete = """
    SELECT m.semaine, m.parcelle, m.hauteur
    FROM mesures AS m JOIN parcelles AS p ON m.parcelle = p.nom
    WHERE p.paillee = 1 AND m.hauteur > ?
"""
print(connexion.execute(requete, (10,)).fetchall())
connexion.close()

Sortie réelle :

Ce que tu vois
[(3, 'A', 22.0), (2, 'A', 14.5), (1, 'A', 8.0)] [('A', 3, 14.83), ('B', 3, 12.67)] [(2, 'A', 14.5), (3, 'A', 22.0)]
  • sqlite3.connect(":memory:") crée une base en mémoire (pour un essai) ; avec un chemin de fichier, la base est enregistrée sur le disque.
  • CREATE TABLE crée une table et ses colonnes, avec leur type (INTEGER, TEXT, REAL).
  • INSERT INTO … VALUES (?, ?, ?) : chaque ? est un paramètre, remplacé par une valeur du tuple. On n'écrit jamais une valeur dans la requête avec une f-string : un texte malveillant pourrait modifier la requête elle-même (l'injection SQL, épisode 25). executemany insère toute une liste.
  • with connexion: ouvre une transaction : à la fin du bloc, les modifications sont validées (commit) ; en cas d'exception, elles sont toutes annulées. Le bloc ne ferme pas la connexion : connexion.close().
  • SELECT … WHERE … ORDER BY … DESC : les lignes qui vérifient la condition, triées (ici par hauteur décroissante).
  • GROUP BY parcelle avec COUNT(*) et AVG(hauteur) : un résumé par groupe, comme groupby de pandas (Pro, épisode 16).
  • JOIN … ON : on associe les lignes de deux tables qui ont une valeur commune (la parcelle).
  • fetchall() renvoie les lignes sous forme de liste de tuples.

Le module stockage.py de « Pousse ».

Code
"""Stockage du jardin dans une base SQLite : mesures et météo."""

import sqlite3
from pathlib import Path

from pousse.modele import Mesure

SCHEMA = """
CREATE TABLE IF NOT EXISTS mesures (
    semaine INTEGER NOT NULL,
    date TEXT NOT NULL,
    parcelle TEXT NOT NULL,
    hauteur REAL NOT NULL,
    PRIMARY KEY (date, parcelle)
);
CREATE TABLE IF NOT EXISTS meteo (
    date TEXT PRIMARY KEY,
    temperature REAL NOT NULL
);
"""


def ouvrir(chemin: Path) -> sqlite3.Connection:
    """Ouvre (ou crée) la base et ses tables."""
    connexion = sqlite3.connect(chemin)
    connexion.executescript(SCHEMA)
    return connexion


def enregistrer_mesures(connexion: sqlite3.Connection, mesures: list[Mesure]) -> None:
    """Ajoute des mesures ; une mesure déjà présente (même date, même parcelle) est remplacée."""
    with connexion:
        connexion.executemany(
            "INSERT OR REPLACE INTO mesures VALUES (?, ?, ?, ?)",
            [(m.semaine, m.date, m.parcelle, m.hauteur) for m in mesures],
        )


def enregistrer_meteo(connexion: sqlite3.Connection, temperatures: dict[str, float]) -> None:
    """Ajoute les températures moyennes quotidiennes."""
    with connexion:
        connexion.executemany(
            "INSERT OR REPLACE INTO meteo VALUES (?, ?)",
            list(temperatures.items()),
        )


def mesures_de(connexion: sqlite3.Connection, parcelle: str) -> list[tuple[int, str, float]]:
    """Renvoie les (semaine, date, hauteur) d'une parcelle, dans l'ordre des semaines."""
    curseur = connexion.execute(
        "SELECT semaine, date, hauteur FROM mesures WHERE parcelle = ? ORDER BY semaine",
        (parcelle,),
    )
    return curseur.fetchall()


def hauteur_et_temperature(connexion: sqlite3.Connection) -> list[tuple[int, str, float, float]]:
    """Pour chaque mesure : semaine, parcelle, hauteur et température moyenne des 7 jours précédents."""
    curseur = connexion.execute(
        """
        SELECT m.semaine, m.parcelle, m.hauteur, ROUND(AVG(t.temperature), 1)
        FROM mesures AS m
        JOIN meteo AS t ON t.date BETWEEN date(m.date, '-6 days') AND m.date
        GROUP BY m.semaine, m.parcelle
        ORDER BY m.semaine, m.parcelle
        """
    )
    return curseur.fetchall()
  • PRIMARY KEY (date, parcelle) : une clé primaire ; deux lignes ne peuvent pas avoir la même date pour la même parcelle. INSERT OR REPLACE remplace alors l'ancienne mesure au lieu de la dupliquer.
  • NOT NULL refuse une valeur manquante.
  • executescript exécute plusieurs instructions d'un coup (la création des tables) ; IF NOT EXISTS évite une erreur quand la table existe déjà.
  • date(m.date, '-6 days') est une fonction de date de SQLite : six jours avant la mesure. La dernière requête calcule, pour chaque mesure, la température moyenne des sept jours qui la précèdent (épisode 22).
Requêtes SQL simulées · table mesures (6 lignes)
Résultat (calculé par sqlite3)
[3]Recherche

Ce que disent les sources

  • Documentation officielle de sqlite3 (Python 3.15.2) — Ne construisez pas les requêtes avec des opérations sur les chaînes : cela permet l'injection SQL ; utilisez des paramètres ? (ou nommés :nom) ; une connexion utilisée avec with valide la transaction si le bloc se termine sans exception, et l'annule sinon, mais ne ferme pas la connexion ; executemany exécute une requête pour chaque élément d'une liste ; fetchall renvoie les lignes restantes sous forme de liste de tuples [1].
  • Tests de ce livre — La version de SQLite dépend de l'installation de Python : 3.45.1 et 3.53.1 sur les ordinateurs de test (sqlite3.sqlite_version) ; les requêtes du livre donnent les mêmes résultats sur les deux (journaux de test).
[4]Mythes
« Une base de données, il faut un serveur. »
SQLite tient dans un fichier, sans serveur ni installation ; il est fourni avec Python.
« SQL, c'est un autre langage, trop dur. »
quelques mots suffisent pour 90 % des besoins d'un projet : SELECT, WHERE, ORDER BY, GROUP BY, JOIN.
« Avec une f-string, c'est plus simple de mettre une valeur dans une requête. »
c'est la porte ouverte à l'injection SQL ; les paramètres ? sont aussi simples et sûrs.
[5]Quiz

1. Que renvoie SELECT parcelle, COUNT(*), ROUND(AVG(hauteur), 2) FROM mesures GROUP BY parcelle sur la table de l'essai ?

une ligne par parcelle, avec le nombre de mesures et la moyenne arrondie (sortie réelle).

2. Pourquoi INSERT INTO mesures VALUES (?, ?, ?) plutôt qu'une f-string ?

les paramètres sont transmis à part, comme des valeurs, jamais comme du SQL.

3. Que fait with connexion: ?

c'est une transaction ; la fermeture se fait avec close().
[6]Kit

🎒 Ton kit — Épisode 21

30 min

Exercice : la base de « Pousse »

  1. Lance essai_sqlite.py et modifie les requêtes : la parcelle B, triée par semaine ; la hauteur maximale par parcelle (MAX).
  2. Ajoute stockage.py au paquet et écris un test qui crée une base dans tmp_path, insère deux mesures et les relit.
  3. Vérifie qu'une même mesure insérée deux fois n'apparaît qu'une fois.

Code à copier : essai_sqlite.py et stockage.py.

Checklist

  • Je sais créer une table, insérer avec ?, interroger avec SELECT.
  • Je sais résumer avec GROUP BY et associer deux tables avec JOIN.
  • Mes modifications sont dans un bloc with connexion:.
Vigilancejamais de f-string ni de + pour mettre une valeur dans une requête SQL, même « juste pour tester ».
[7]Journal

✍️ Journal de bord

Quelle question sur tes données de TP aurait été plus simple à poser en SQL qu'avec des boucles ?

[8]Sources
  1. Python Software Foundation, sqlite3 — DB-API 2.0 interface for SQLite databases, documentation de Python 3.15.2, consulté le 30/10/2026.
  2. Programmes exécutés le 30/10/2026 sous Python 3.12.13 (SQLite 3.53.1), 3.15.0 et 3.15.2 (SQLite 3.45.1) (journaux de test de ce livre).
22
S11 · JEUDI · Pratiquer

Interroger le jardin

Écrire la question en français, puis en SQL, puis la vérifier à la main sur quelques lignes ; une fonction Python par requête, testée sur une base temporaire.

⏱ 30 min de pratique · 5 min de quiz
[1]Scénario
Yanis · Jeudi, 17 h

La question du projet tient en une phrase : « Les radis poussent-ils plus vite quand il a fait chaud la semaine précédente ? » Yanis veut, pour chaque mesure, la température moyenne des sept jours qui la précèdent. Sa première requête renvoie… 1 826 lignes pour 22 mesures.

[2]Méthode

« La question en français, puis en SQL, puis à la main »

  1. Écrire la question en français, avec ce qu'on veut par ligne de résultat : « pour chaque mesure : sa semaine, sa parcelle, sa hauteur, la moyenne des températures des 7 jours qui finissent le jour de la mesure ».
  2. La traduire en SQL, clause par clause : FROM (les tables), JOIN … ON (la condition qui associe), GROUP BY (une ligne par quoi ?), SELECT (quelles colonnes), ORDER BY.
  3. Compter les lignes attendues avant de lancer : ici, une par mesure, soit 22. Les 1 826 lignes de Yanis venaient d'un JOIN sans condition : chaque mesure associée à chacun des 83 jours de météo (22 × 83).
  4. Vérifier à la main sur une petite base de test.
  5. Ranger la requête dans une fonction de stockage.py, avec son test.

La fonction hauteur_et_temperature (dans stockage.py, épisode 21) et son test, sur une base temporaire dont on connaît la réponse :

Code
from pousse.modele import Mesure
from pousse.stockage import enregistrer_mesures, enregistrer_meteo, hauteur_et_temperature, mesures_de, ouvrir


def test_mesures_triees_et_remplacees(tmp_path):
    connexion = ouvrir(tmp_path / "essai.db")
    enregistrer_mesures(connexion, [Mesure(2, "2026-04-06", "A", 14.5), Mesure(1, "2026-03-30", "A", 8.0)])
    enregistrer_mesures(connexion, [Mesure(2, "2026-04-06", "A", 15.0)])
    assert mesures_de(connexion, "A") == [(1, "2026-03-30", 8.0), (2, "2026-04-06", 15.0)]
    assert mesures_de(connexion, "B") == []


def test_temperature_des_sept_jours_precedents(tmp_path):
    connexion = ouvrir(tmp_path / "essai.db")
    enregistrer_mesures(connexion, [Mesure(2, "2026-04-06", "A", 14.5)])
    enregistrer_meteo(connexion, {"2026-03-30": 99.0, "2026-03-31": 10.0, "2026-04-06": 12.0})
    assert hauteur_et_temperature(connexion) == [(2, "A", 14.5, 11.0)]


def test_une_entree_piegee_reste_une_simple_valeur(tmp_path):
    connexion = ouvrir(tmp_path / "essai.db")
    enregistrer_mesures(connexion, [Mesure(1, "2026-03-30", "A", 8.0)])
    assert mesures_de(connexion, "A' OR '1'='1") == []

Dans le deuxième test, la mesure du 6 avril est associée aux jours du 31 mars au 6 avril : 10,0 et 12,0 °C, moyenne 11,0. La température du 30 mars (99,0, volontairement absurde) est hors de la fenêtre : si elle était comptée, le test le dirait. Le troisième test glisse une entrée piégée (A' OR '1'='1) : grâce au paramètre ?, elle est traitée comme un nom de parcelle, qui n'existe pas (voir épisode 25).

Sur les vraies données (charger_base.py, avec la météo en cache de l'épisode 20) :

Code
"""Charge le cahier du jardin et la météo (cache) dans pousse.db, puis croise hauteur et température."""
from pathlib import Path

from pousse.lecture import lire_cahier
from pousse.meteo import lire_cache, temperatures
from pousse.stockage import enregistrer_mesures, enregistrer_meteo, hauteur_et_temperature, ouvrir

mesures, ecartees = lire_cahier(Path("donnees/mesures-jardin.csv"))
connexion = ouvrir(Path("pousse.db"))
enregistrer_mesures(connexion, mesures)
enregistrer_meteo(connexion, temperatures(lire_cache(Path("meteo-cache.json"))))
print("Mesures :", connexion.execute("SELECT COUNT(*) FROM mesures").fetchone()[0],
      "· jours de météo :", connexion.execute("SELECT COUNT(*) FROM meteo").fetchone()[0])
print("semaine  parcelle  hauteur  température (7 j)")
for semaine, parcelle, hauteur, temperature in hauteur_et_temperature(connexion)[:6]:
    print(f"{semaine:>7}  {parcelle:^8}  {hauteur:>7}  {temperature:>6} °C")
connexion.close()

Sortie réelle (les températures viennent de la réponse d'exemple, valeurs inventées) :

Ce que tu vois
Mesures : 22 · jours de météo : 83 semaine parcelle hauteur température (7 j) 1 A 8.0 9.7 °C 1 B 7.5 9.7 °C 2 A 14.5 11.2 °C 2 B 12.0 11.2 °C 3 A 22.0 8.6 °C 3 B 18.5 8.6 °C

22 mesures, 83 jours de météo, et une ligne par mesure : la requête a le bon nombre de lignes.

[3]Pourquoi ça marche

Écrire la question en français oblige à dire ce qu'est une ligne du résultat : c'est presque toujours là que se cache l'erreur (un JOIN sans condition, un GROUP BY oublié). Compter les lignes attendues est une vérification gratuite. Et une base de test minuscule, construite pour l'occasion, donne un cas calculé à la main (Boost, épisode 4), avec un piège pour vérifier que la fenêtre de sept jours est la bonne.

À retenirune ligne de résultat = quoi ? ; le nombre de lignes attendu ; un cas calculé à la main ; une fonction et son test par requête.
[4]Entraînement

Bon réflexe ou piège ?

J'écris JOIN meteo sans ON, je verrai bien.
chaque mesure est associée à chaque jour de météo (22 × 83 = 1 826 lignes).
Avant de lancer la requête, j'écris combien de lignes j'attends.
un nombre faux signale une erreur de JOIN ou de GROUP BY.
Je teste ma requête sur la base complète uniquement.
tu ne connais pas la bonne réponse ; teste sur une petite base construite exprès.
Je glisse une valeur absurde hors de la fenêtre pour vérifier qu'elle n'est pas comptée.
si elle l'était, la moyenne serait fausse de façon visible.
Je copie la requête dans trois fichiers différents.
une seule fonction dans stockage.py, appelée partout.
[5]Quiz

1. Une requête doit donner une ligne par mesure (22 mesures). Elle en renvoie 1 826. Cause probable ?

22 mesures × 83 jours de météo = 1 826 associations.

2. Dans le test, pourquoi la température du 30 mars vaut-elle 99,0 ?

le résultat attendu, 11,0, n'est juste que si elle est exclue.

3. Que garantit GROUP BY m.semaine, m.parcelle ?

les jours associés à une même mesure sont résumés par AVG.
[6]Kit

🎒 Ton kit — Épisode 22

30 min

Exercice : la question du projet

  1. Écris en français la question « croissance et température », ligne de résultat comprise.
  2. Ajoute à stockage.py une fonction qui renvoie, pour chaque parcelle, la hauteur moyenne des semaines où la température moyenne dépassait un seuil donné en paramètre (?).
  3. Écris son test sur une base temporaire, avec un piège.
  4. Lance charger_base.py et vérifie le nombre de lignes.

Code à copier : test_stockage.py et charger_base.py.

Checklist

  • J'ai écrit la question en français avant le SQL.
  • J'ai prévu le nombre de lignes et vérifié.
  • Chaque requête est une fonction testée.
Vigilanceavec des données inventées ou une seule saison, une corrélation n'est pas une preuve ; ton analyse le dira (épisode 27).
[7]Journal

✍️ Journal de bord

Écris en une phrase la question scientifique de ton projet, et la ligne de résultat qui y répond.

[8]Sources
  1. Python Software Foundation, sqlite3 (paramètres, transactions), consulté le 30/10/2026.
  2. Programmes et tests exécutés le 30/10/2026 sous Python 3.12.13, 3.15.0 et 3.15.2 (journaux de test de ce livre).
Semaine 12 · L'application web
23
S12 · MARDI · Comprendre

Une petite application web

Flask : une application, des routes, des gabarits HTML qui échappent le texte, un formulaire validé, une figure servie à la demande, un lancement en local.

⏱ 30 min de lecture · 5 min de quiz
[1]Scénario
Yanis · Mardi, 20 h

L'encadrante du laboratoire a prévenu : « Le jour de l'entretien, montrez-moi votre outil. » Elle n'installera pas Python pour voir des lignes de texte dans un terminal. Il faut une page : la figure du jardin, le tableau de chaque parcelle, et un formulaire pour ajouter une mesure.

[2]Concept

Une application web avec Flask

Une application web est un programme qui répond à des requêtes HTTP (épisode 19) en renvoyant des pages HTML. Ton navigateur est le client ; l'application, lancée sur ton ordinateur, est le serveur. Flask est une bibliothèque légère pour en écrire une (une dépendance de pyproject.toml).

app.py :

Code
"""Application web locale de Pousse (Flask) : tableau, figure et ajout d'une mesure."""

from io import BytesIO
from pathlib import Path

from flask import Flask, Response, abort, redirect, render_template, request, url_for
from flask.typing import ResponseReturnValue
from matplotlib.figure import Figure

from pousse.lecture import lire_taille
from pousse.modele import Mesure
from pousse.stockage import enregistrer_mesures, mesures_de, ouvrir

PARCELLES = {"A": "paillée", "B": "nue"}

app = Flask(__name__)
app.config["BASE"] = Path("pousse.db")


@app.get("/")
def accueil() -> str:
    """Page d'accueil : la figure et le nombre de mesures de chaque parcelle."""
    connexion = ouvrir(app.config["BASE"])
    tableau = {nom: mesures_de(connexion, nom) for nom in PARCELLES}
    connexion.close()
    return render_template("accueil.html", tableau=tableau, parcelles=PARCELLES)


@app.get("/parcelle/<nom>")
def parcelle(nom: str) -> str:
    """Tableau des mesures d'une parcelle ; erreur 404 si elle n'existe pas."""
    if nom not in PARCELLES:
        abort(404)
    connexion = ouvrir(app.config["BASE"])
    lignes = mesures_de(connexion, nom)
    connexion.close()
    return render_template("parcelle.html", nom=nom, etat=PARCELLES[nom], lignes=lignes)


@app.get("/figure.png")
def figure() -> Response:
    """La figure des hauteurs, produite à chaque demande (sans pyplot)."""
    connexion = ouvrir(app.config["BASE"])
    fig = Figure(figsize=(6, 4))
    ax = fig.subplots()
    for nom, etat in PARCELLES.items():
        lignes = mesures_de(connexion, nom)
        ax.plot([s for s, d, h in lignes], [h for s, d, h in lignes], marker="o", label=f"parcelle {nom} ({etat})")
    connexion.close()
    ax.set_title("Hauteur des radis du jardin partagé")
    ax.set_xlabel("Semaine")
    ax.set_ylabel("Hauteur (mm)")
    ax.legend()
    tampon = BytesIO()
    fig.savefig(tampon, format="png")
    return Response(tampon.getvalue(), mimetype="image/png")


@app.route("/ajouter", methods=["GET", "POST"])
def ajouter() -> ResponseReturnValue:
    """Formulaire d'ajout d'une mesure ; refuse une saisie invalide avec un message (code 400)."""
    if request.method == "GET":
        return render_template("ajouter.html", erreur=None)
    try:
        mesure = Mesure(
            semaine=int(request.form["semaine"]),
            date=request.form["date"],
            parcelle=request.form["parcelle"],
            hauteur=lire_taille(request.form["hauteur"]),
        )
        if mesure.parcelle not in PARCELLES:
            raise ValueError(f"parcelle inconnue : {mesure.parcelle!r}")
    except ValueError as erreur:
        return render_template("ajouter.html", erreur=str(erreur)), 400
    connexion = ouvrir(app.config["BASE"])
    enregistrer_mesures(connexion, [mesure])
    connexion.close()
    return redirect(url_for("accueil"))
  • app = Flask(__name__) crée l'application ; app.config["BASE"] range un réglage : le chemin de la base (les tests le remplacent par une base temporaire).
  • Une route associe une adresse à une fonction : @app.get("/") est un décorateur (comme @dataclass, épisode 11) qui inscrit accueil comme réponse à GET /. <nom> dans l'adresse devient un paramètre de la fonction.
  • abort(404) répond « page introuvable » ; redirect(url_for("accueil")) renvoie vers l'accueil après un ajout réussi.
  • render_template("accueil.html", …) remplit un gabarit HTML du dossier templates/ avec des valeurs.
  • Le formulaire (POST /ajouter) est une entrée non fiable : chaque champ est converti et vérifié (int, lire_taille, parcelle connue) ; une saisie invalide est refusée avec un message et le code 400 (épisode 9).
  • La figure est produite à la demande avec Figure (sans pyplot, comme le recommande matplotlib dans un serveur web) et envoyée en PNG depuis la mémoire (BytesIO).

Le gabarit de l'accueil :

Fichier à copier (HTML)
<!doctype html>
<html lang="fr">
<head><meta charset="utf-8"><title>Pousse — le jardin partagé</title></head>
<body>
  <h1>Pousse — le jardin partagé</h1>
  <p><img src="{{ url_for('figure') }}" alt="Hauteur des radis par semaine et par parcelle"></p>
  {% for nom, lignes in tableau.items() %}
    <h2><a href="{{ url_for('parcelle', nom=nom) }}">Parcelle {{ nom }}</a> ({{ parcelles[nom] }}) : {{ lignes|length }} mesures</h2>
  {% endfor %}
  <p><a href="{{ url_for('ajouter') }}">Ajouter une mesure</a></p>
  <p><small>Données du jardin inventées. Météo : Open-Meteo.com (CC BY 4.0).</small></p>
</body>
</html>

{{ … }} insère une valeur, {% for … %} répète un morceau de page. Jinja, le moteur de gabarits de Flask, échappe automatiquement les valeurs : un texte comme <b>grand</b> s'affiche tel quel, sans devenir du HTML. La dernière ligne cite la source des données météo (licence CC BY 4.0, épisode 19).

Lancer l'application (dans l'environnement du projet, depuis son dossier) :

Dans le terminal
$ flask --app pousse.app run --port 5050

Le journal du serveur (extrait réel) :

Ce que tu vois
* Serving Flask app 'pousse.app' WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. * Running on http://127.0.0.1:5050 127.0.0.1 - - "GET / HTTP/1.1" 200 - 127.0.0.1 - - "GET /parcelle/Z HTTP/1.1" 404 - 127.0.0.1 - - "POST /ajouter HTTP/1.1" 400 - 127.0.0.1 - - "GET /figure.png HTTP/1.1" 200 -

Tu ouvres http://127.0.0.1:5050 dans ton navigateur : 127.0.0.1 désigne ton propre ordinateur ; personne d'autre n'y a accès. L'avertissement le rappelle : ce serveur de développement n'est pas fait pour être mis en ligne.

Ce que l'application renvoie (essai_app.py, qui joue le rôle du navigateur) :

Code
"""Interroge l'application lancée en local (flask --app pousse.app run) et affiche ses réponses."""
import requests

BASE = "http://127.0.0.1:5050"
reponse = requests.get(BASE + "/", timeout=5)
print(reponse.status_code, reponse.headers["Content-Type"])
for ligne in reponse.text.splitlines():
    if "<h2>" in ligne or "<img" in ligne:
        print("  " + ligne.strip())
print(requests.get(BASE + "/parcelle/Z", timeout=5).status_code)
reponse = requests.post(BASE + "/ajouter", timeout=5, allow_redirects=False,
                        data={"semaine": "13", "date": "2026-06-22", "parcelle": "A", "hauteur": "<b>grand</b>"})
print(reponse.status_code, [l.strip() for l in reponse.text.splitlines() if "refusée" in l])
reponse = requests.get(BASE + "/figure.png", timeout=5)
print(reponse.status_code, reponse.headers["Content-Type"], len(reponse.content) > 10_000)
Ce que tu vois
200 text/html; charset=utf-8 <p><img src="/figure.png" alt="Hauteur des radis par semaine et par parcelle"></p> <h2><a href="/parcelle/A">Parcelle A</a> (paillée) : 11 mesures</h2> <h2><a href="/parcelle/B">Parcelle B</a> (nue) : 11 mesures</h2> 404 400 ['<p><strong>Mesure refusée : hauteur illisible : &#39;&lt;b&gt;grand&lt;/b&gt;&#39;</strong></p>'] 200 image/png True

La saisie <b>grand</b> est refusée, et le message l'affiche échappée (&lt;b&gt;) : elle ne peut pas injecter de HTML dans la page.

Le mode debug. flask --app pousse.app run --debug recharge l'application à chaque modification et affiche un débogueur dans le navigateur en cas d'erreur. Ce débogueur permet d'exécuter du Python depuis le navigateur : il est réservé à ton ordinateur, jamais sur un serveur accessible par d'autres (épisode 25).

Routes simulées · http://127.0.0.1:5050
[3]Recherche

Ce que disent les sources

  • Documentation officielle de Flask, Quickstart — @app.route() associe une adresse à une fonction ; flask --app hello run lance le serveur de développement, qui n'écoute que l'ordinateur local par défaut et n'est pas fait pour la production ; le mode debug permet d'exécuter du Python depuis le navigateur : « ne jamais l'utiliser en production » ; les gabarits Jinja échappent automatiquement le HTML ; abort, redirect, url_for et request.form servent aux erreurs, redirections et formulaires [1].
  • Galerie de matplotlib (3.11.2), « Embedding in a web application server (Flask) » — Dans un serveur web, il est fortement déconseillé d'utiliser pyplot, qui garde une référence à chaque figure (fuites de mémoire) ; depuis matplotlib 3.1, on crée directement une Figure et on l'enregistre dans un tampon en mémoire [2].
  • Tests de ce livre — Flask 3.1.3, installé depuis PyPI le 30/10/2026 ; l'application a été lancée en local et interrogée sous Python 3.12.13, 3.15.0 et 3.15.2 (journaux de test) [3].
[4]Mythes
« Une application web doit être en ligne. »
« Pousse » tourne sur ton ordinateur (127.0.0.1) ; c'est suffisant pour la montrer en entretien, sans hébergement ni compte.
« Le mode debug, c'est pratique, on le garde. »
il permet d'exécuter du code depuis le navigateur ; jamais ailleurs que sur ton ordinateur.
« Un formulaire, c'est de la saisie : on lui fait confiance. »
tout ce qui arrive d'un formulaire est non fiable ; on convertit, on vérifie, on refuse avec un message.
[5]Quiz

1. Que renvoie l'application pour /parcelle/Z ?

la parcelle n'existe pas : abort(404) (sortie réelle).

2. Que devient la saisie <b>grand</b> dans le message d'erreur ?

Jinja échappe automatiquement les valeurs (sortie réelle).

3. Que signifie http://127.0.0.1:5050 ?

127.0.0.1 désigne l'ordinateur local.
[6]Kit

🎒 Ton kit — Épisode 23

30 min

Exercice : la page du jardin

  1. Ajoute flask et matplotlib aux dépendances, puis app.py et les trois gabarits au paquet ; réinstalle.
  2. Charge la base avec charger_base.py, lance flask --app pousse.app run --port 5050 et ouvre la page dans ton navigateur.
  3. Ajoute une mesure par le formulaire, puis essaie une hauteur « n.m. » : elle doit être refusée.
  4. Écris test_app.py (client de test de Flask, base dans tmp_path) et fais-le passer.

Code à copier : app.py, les gabarits et test_app.py (kit de l'épisode 24).

Checklist

  • L'application tourne en local et affiche la figure et les parcelles.
  • Une saisie invalide est refusée avec un message.
  • La source des données météo est citée sur la page.
Vigilancen'ajoute jamais --host=0.0.0.0 (ouverture au réseau) ni --debug sur un ordinateur partagé ou un réseau public.
[7]Journal

✍️ Journal de bord

Qu'est-ce que l'encadrante doit voir en moins d'une minute sur la page de « Pousse » ? Ta page le montre-t-elle ?

[8]Sources
  1. Pallets, Flask — Quickstart, consulté le 30/10/2026.
  2. Matplotlib, Embedding in a web application server (Flask), galerie de la version 3.11.2, consulté le 30/10/2026.
  3. Application lancée et interrogée le 30/10/2026 sous Python 3.12.13, 3.15.0 et 3.15.2 avec Flask 3.1.3 (journaux de test de ce livre).
24
S12 · JEUDI · Pratiquer · Bilan d'arc

Construire le projet : le bilan

Ton outil de synthèse, le récapitulatif de l'arc et ton badge.

⏱ 30 min · arbre de décision · carrousel · quiz final
[1]Scénario
Yanis · Jeudi, 18 h

Awa clone le dépôt sur son ordinateur, crée l'environnement, tape python -m pip install -e ".[dev]", puis python -m pytest : 24 tests au vert. Elle charge la base, lance l'application, ouvre son navigateur : la figure du jardin s'affiche, la météo vient du cache, la base s'est créée toute seule. Elle ajoute une mesure. « Cinq commandes. Ton encadrante va adorer. »

[2]Outil

« Où ranger ce code ? »

Aide à la décision interactive
[3]Carrousel

🎨 Carrousel récapitulatif — Arc 3

[4]Quiz final

1. Tu ajoutes pandas aux dépendances de pyproject.toml. Que faire ensuite ?

les métadonnées ont changé ; l'installation modifiable doit être refaite.

2. Où placer la fonction qui calcule la croissance moyenne des semaines chaudes à partir de la base ?

la route appelle ; elle ne calcule pas (arbre de l'épisode).

3. L'API répond 429. Quelle est la meilleure réaction ?

429 signifie « trop de requêtes » ; le cache évite les appels inutiles.

4. Quelle requête est sûre ?

seule la version paramétrée empêche une valeur de modifier la requête.

5. Pourquoi l'application n'utilise-t-elle pas pyplot ?

c'est la recommandation de matplotlib pour un serveur web.
[5]Kit

🎒 Ton kit de fin d'arc

35 min

« Pousse » complet

Synthèse : le paquet, ses six modules, ses gabarits, ses tests, ses essais et ses données.

Le dossier du projet
pousse/ ├── pyproject.toml ├── donnees/mesures-jardin.csv ├── essais/ charger_base.py, essai_app.py, essai_cache.py, │ essai_meteo.py, essai_sqlite.py, serveur_essai.py ├── src/pousse/ │ ├── __init__.py __main__.py │ ├── lecture.py modele.py analyse.py │ ├── meteo.py stockage.py app.py │ └── templates/ accueil.html parcelle.html ajouter.html └── tests/ ├── reponse-exemple-meteo.json (valeurs inventées, pour les tests) └── test_analyse.py test_app.py test_lecture.py test_meteo.py test_modele.py test_stockage.py

Installer et lancer, en cinq commandes (dans un environnement virtuel neuf, depuis le dossier du projet) :

Dans le terminal
$ python -m pip install -e ".[dev]" $ python -m pytest $ python essais/essai_cache.py $ python essais/charger_base.py $ flask --app pousse.app run --port 5050

(La troisième commande appelle la vraie API une seule fois et enregistre le cache ; sans réseau, copie la réponse d'exemple sous le nom meteo-cache.json pour essayer.)

test_app.py :

Code
import pytest

from pousse.app import app


@pytest.fixture
def client(tmp_path):
    app.config["BASE"] = tmp_path / "essai.db"
    return app.test_client()


def test_accueil(client):
    reponse = client.get("/")
    assert reponse.status_code == 200
    assert "Parcelle A" in reponse.get_data(as_text=True)


def test_parcelle_inconnue(client):
    assert client.get("/parcelle/Z").status_code == 404


def test_ajout_refuse_une_hauteur_invalide(client):
    reponse = client.post("/ajouter", data={"semaine": "13", "date": "2026-06-22", "parcelle": "A", "hauteur": "n.m."})
    assert reponse.status_code == 400
    assert "hauteur absente" in reponse.get_data(as_text=True)


def test_ajout_valide_puis_affichage(client):
    reponse = client.post("/ajouter", data={"semaine": "13", "date": "2026-06-22", "parcelle": "A", "hauteur": "75,5"})
    assert reponse.status_code == 302
    assert "75.5" in client.get("/parcelle/A").get_data(as_text=True)


def test_figure(client):
    reponse = client.get("/figure.png")
    assert reponse.mimetype == "image/png"

@pytest.fixture déclare une fixture : une fonction qui prépare ce dont les tests ont besoin (ici, un client de test relié à une base temporaire) ; chaque test qui a un paramètre client la reçoit.

Tous les tests (python -m pytest -v, sortie réelle) :

Rapport de pytest
============================= test session starts ============================== platform linux -- Python 3.15.0, pytest-9.2.0, pluggy-1.7.0 -- .venv/bin/python rootdir: …/pousse configfile: pyproject.toml collecting ... collected 24 items tests/test_analyse.py::test_croissance_moyenne_d_une_seule_mesure PASSED [ 4%] tests/test_analyse.py::test_cahier_du_jardin PASSED [ 8%] tests/test_app.py::test_accueil PASSED [ 12%] tests/test_app.py::test_parcelle_inconnue PASSED [ 16%] tests/test_app.py::test_ajout_refuse_une_hauteur_invalide PASSED [ 20%] tests/test_app.py::test_ajout_valide_puis_affichage PASSED [ 25%] tests/test_app.py::test_figure PASSED [ 29%] tests/test_lecture.py::test_virgule_et_point PASSED [ 33%] tests/test_lecture.py::test_hauteur_absente[] PASSED [ 37%] tests/test_lecture.py::test_hauteur_absente[n.m.] PASSED [ 41%] tests/test_lecture.py::test_hauteur_absente[ ] PASSED [ 45%] tests/test_lecture.py::test_hauteur_illisible PASSED [ 50%] tests/test_lecture.py::test_cahier_avec_lignes_ecartees PASSED [ 54%] tests/test_meteo.py::test_temperatures_ignore_les_jours_sans_valeur PASSED [ 58%] tests/test_meteo.py::test_cache_aller_retour PASSED [ 62%] tests/test_meteo.py::test_obtenir_meteo_utilise_le_cache_sans_reseau PASSED [ 66%] tests/test_modele.py::test_parcelle_vide PASSED [ 70%] tests/test_modele.py::test_hauteur_finale_quel_que_soit_l_ordre PASSED [ 75%] tests/test_modele.py::test_croissance_avec_semaine_manquante PASSED [ 79%] tests/test_modele.py::test_mesure_d_une_autre_parcelle PASSED [ 83%] tests/test_modele.py::test_parcelle_inconnue PASSED [ 87%] tests/test_stockage.py::test_mesures_triees_et_remplacees PASSED [ 91%] tests/test_stockage.py::test_temperature_des_sept_jours_precedents PASSED [ 95%] tests/test_stockage.py::test_une_entree_piegee_reste_une_simple_valeur PASSED [100%] ============================== 24 passed in 0.65s ==============================

mypy (python -m mypy src tests) :

Rapport de mypy
Success: no issues found in 14 source files
Fichier fourni · le projet de référence

Décompresse-le dans un dossier d'entraînement, lis le README, puis suis ses commandes dans un environnement virtuel neuf.

Consigne : vérifie l'installation en cinq commandes depuis un clone neuf ; fais un commit « Pousse complet : paquet, météo, base, application » et pousse-le sur ton dépôt privé (Pro, épisode 18). Puis exporte ton carnet de bord.

Checklist

  • J'ai exporté mon carnet (bouton dans le menu).
  • 24 tests au vert et mypy sans erreur.
  • Le projet s'installe et se lance depuis un clone neuf.
[6]Journal

✍️ Journal de bord — bilan

Si tu devais montrer une seule partie de « Pousse » en entretien, laquelle choisirais-tu, et pourquoi ?

✦ BÂTISSEUSE · BÂTISSEUR D'APPLICATIONS · ARC 3 ✦

Bravo ! Tu as terminé l'arc 3. Tu sais organiser un projet en paquet, interroger une API, stocker tes données dans une base et construire une petite application web qui tourne sur ton ordinateur.

ARC 4 · SEMAINES 13 À 16

Publier et transmettre

« Pousse » marche : il reste à le rendre sûr, à le documenter, à le publier, puis à savoir en parler. En quatre semaines, tu sécurises ton projet, tu écris son README, tu prépares ton entretien de stage et tu apprends à aider ceux qui débutent.

Semaine 13 · La sécurité
25
S13 · MARDI · Comprendre

Un projet qui ne met personne en danger

Secrets hors du code et de l'historique, entrées non fiables (injection SQL, HTML), mode debug, dépendances vérifiées et à jour, aucune donnée personnelle.

⏱ 30 min de lecture · 5 min de quiz
[1]Scénario
Yanis · Mardi, 18 h

Avant de rendre son dépôt public, Yanis demande à une doctorante de l'équipe de jeter un œil à une ancienne version de « Pousse », écrite avant l'arc 3. En cinq minutes, elle trouve une requête SQL construite avec une f-string, une clé secrète écrite dans le code, un message d'erreur affiché sans échappement et l'application lancée en mode debug, ouverte au réseau. « Rien de grave tant que c'est sur ton ordinateur. Mais une fois publié, tout le monde le lit. »

[2]Concept

Les quatre familles de risques

1. Les entrées non fiables : l'injection SQL. Tout ce qui vient de l'extérieur (un formulaire, une adresse, un fichier) peut contenir n'importe quoi. Une requête construite avec une f-string laisse la saisie modifier la requête elle-même :

Code
"""Une requête construite avec une f-string, puis la même avec un paramètre : sur une base d'essai."""
import sqlite3

connexion = sqlite3.connect(":memory:")
connexion.execute("CREATE TABLE mesures (semaine INTEGER, parcelle TEXT, hauteur REAL)")
with connexion:
    connexion.executemany("INSERT INTO mesures VALUES (?, ?, ?)",
                          [(1, "A", 8.0), (1, "B", 7.5), (1, "privee", 99.0)])


def mesures_de_piege(nom):
    requete = f"SELECT * FROM mesures WHERE parcelle = '{nom}'"
    print("   requête envoyée :", requete)
    return connexion.execute(requete).fetchall()


def mesures_de(nom):
    return connexion.execute("SELECT * FROM mesures WHERE parcelle = ?", (nom,)).fetchall()


saisie = "A' OR '1'='1"
print("f-string :", mesures_de_piege(saisie))
print("paramètre :", mesures_de(saisie))
print("paramètre, saisie normale :", mesures_de("A"))

Sortie réelle :

Ce que tu vois
requête envoyée : SELECT * FROM mesures WHERE parcelle = 'A' OR '1'='1' f-string : [(1, 'A', 8.0), (1, 'B', 7.5), (1, 'privee', 99.0)] paramètre : [] paramètre, saisie normale : [(1, 'A', 8.0)]

La saisie A' OR '1'='1 ferme les guillemets et ajoute une condition toujours vraie : la f-string renvoie toutes les lignes, y compris une parcelle qui ne devait pas être visible. Avec le paramètre ?, la même saisie est traitée comme une simple valeur : aucune parcelle ne porte ce nom (épisode 21).

2. Les entrées non fiables : le HTML. Afficher une saisie sans l'échapper permet d'injecter du HTML, ou un script, dans la page :

Code
"""Le même message d'erreur affiché avec et sans échappement, dans un gabarit Jinja (moteur de Flask)."""
from jinja2 import Environment

env = Environment(autoescape=True)
saisie = "<script>alert('jardin')</script>"
sur = env.from_string("<p>Mesure refusée : {{ erreur }}</p>")
piege = env.from_string("<p>Mesure refusée : {{ erreur|safe }}</p>")
print(sur.render(erreur=saisie))
print(piege.render(erreur=saisie))

Sortie réelle :

Ce que tu vois
<p>Mesure refusée : &lt;script&gt;alert(&#39;jardin&#39;)&lt;/script&gt;</p> <p>Mesure refusée : <script>alert('jardin')</script></p>

Jinja échappe automatiquement (première ligne). Le filtre |safe désactive l'échappement : le script arrive tel quel dans la page, et le navigateur l'exécuterait. On ne met jamais |safe sur une donnée saisie par quelqu'un. Et jamais eval ou exec sur une entrée : ce serait exécuter le code d'un inconnu.

3. Les secrets. Un mot de passe, une clé d'API, une clé secrète d'application ne s'écrivent jamais dans le code. On les lit dans une variable d'environnement, définie sur l'ordinateur et jamais versionnée : os.environ["POUSSE_SECRET_KEY"], ou dans un fichier .env listé dans .gitignore (Pro, épisode 18). Et un secret commité un jour reste dans l'historique, même effacé ensuite (démonstration réelle à l'épisode 26) : il faut alors le considérer comme connu de tous, et le changer. « Pousse » n'utilise aucun secret : la meilleure clé est celle qu'on n'a pas besoin d'avoir.

4. La configuration et les dépendances. Le mode debug de Flask permet d'exécuter du Python depuis le navigateur : jamais ailleurs que sur ton ordinateur, et jamais avec --host=0.0.0.0 (épisode 23). Les dépendances sont du code écrit par d'autres : on vérifie leur nom avant de les installer (Pro, épisode 14), on garde leurs versions dans pyproject.toml ou requirements.txt, on les met à jour, et on vérifie qu'elles n'ont pas de vulnérabilité connue (épisode 26).

Et les données personnelles. « Pousse » n'en collecte aucune : pas de compte, pas de nom, une ville et non une adresse. Si un projet devait en traiter, ce serait une autre affaire : informer, minimiser, protéger (règles de la CNIL).

RisqueDans la version piégéeDans « Pousse »
Injection SQLf-string dans la requêteparamètres ? (stockage.py)
Injection HTML{{ erreur|safe }}échappement automatique de Jinja
Secretclé écrite dans le codeaucun secret ; sinon variable d'environnement
Configurationdebug activé, ouvert au réseauflask run local, sans debug
[3]Recherche

Ce que disent les sources

  • OWASP Top 10:2025 — La huitième édition du classement des risques des applications web place l'injection en 5e position (A05) et crée la catégorie A03 « Défaillances de la chaîne d'approvisionnement logicielle » (les dépendances), qui élargit l'ancienne catégorie des composants vulnérables ou obsolètes ; la mauvaise configuration est en 2e position (A02) [1].
  • Documentation officielle de sqlite3 — Construire une requête avec des opérations sur les chaînes permet l'injection SQL ; la documentation montre une saisie qui transforme la condition WHERE pour renvoyer toutes les lignes, et recommande les paramètres [2].
  • Documentation de Flask, « Security Considerations » (3.1.x) — Flask configure Jinja pour échapper automatiquement toutes les valeurs, ce qui écarte les problèmes de XSS dans les gabarits ; il met en garde contre Markup appliqué à des données soumises par les utilisateurs [3].
  • Documentation de Flask, Quickstart — Le débogueur permet d'exécuter du Python depuis le navigateur : ne jamais utiliser le mode debug en production [4].
[4]Mythes
« Mon projet est trop petit pour intéresser un pirate. »
les attaques sont automatisées : des robots parcourent les dépôts publics à la recherche de clés et de failles connues, quel que soit le projet.
« Un dépôt privé, c'est un coffre-fort. »
il peut devenir public, être partagé, cloné ; un secret n'a rien à faire dans un dépôt, privé ou non.
« J'ai effacé le mot de passe du fichier, c'est réglé. »
il reste dans l'historique Git ; il faut le changer.
[5]Quiz

1. Avec la f-string, que renvoie la saisie A' OR '1'='1 ?

la condition devient toujours vraie (sortie réelle : 3 lignes, dont la parcelle « privee »).

2. Que fait le filtre |safe dans un gabarit Jinja ?

son nom est trompeur : il dit à Jinja « fais-moi confiance », à n'utiliser que pour un contenu que tu as écrit toi-même.

3. Où ranger une clé d'API ?

le code et le dépôt ne contiennent jamais de secret.
[6]Kit

🎒 Ton kit — Épisode 25

25 min

Exercice : trouver et corriger les failles

  1. Lance piege_sql.py et piege_xss.py sur ton ordinateur ; essaie d'autres saisies.
  2. Dans ton propre « Pousse », cherche toute requête SQL qui contiendrait f" ou +, tout |safe, tout secret écrit en dur, tout debug=True.
  3. Remplis le tableau « risque / dans mon projet / correction ».
  4. Écris un test qui vérifie qu'une saisie piégée ne renvoie rien (comme test_une_entree_piegee_reste_une_simple_valeur, épisode 22).

Code à copier : piege_sql.py et piege_xss.py, à lancer seulement sur ton ordinateur, dans un dossier d'entraînement.

Checklist

  • Toutes mes requêtes SQL utilisent des paramètres.
  • Aucune saisie n'est affichée avec |safe.
  • Aucun secret n'est dans mon code ni dans mon historique.
Vigilanceces exemples servent à protéger ton propre projet ; essayer une injection sur un site ou un service qui n'est pas le tien est interdit par la loi.
[7]Journal

✍️ Journal de bord

Laquelle des quatre failles aurais-tu laissée passer avant cet épisode ? Comment la repérerais-tu maintenant en relisant un code ?

[8]Sources
  1. OWASP Foundation, OWASP Top 10:2025 — Introduction, consulté le 30/10/2026.
  2. Python Software Foundation, sqlite3 (« How to use placeholders to bind values in SQL queries »), documentation de Python 3.15.2, consulté le 30/10/2026.
  3. Pallets, Flask — Security Considerations, version 3.1.x, consulté le 30/10/2026.
  4. Pallets, Flask — Quickstart, « Debug Mode », consulté le 30/10/2026.
  5. Programmes exécutés le 30/10/2026 sous Python 3.12.13, 3.15.0 et 3.15.2 (journaux de test de ce livre).
26
S13 · JEUDI · Pratiquer

L'audit de sécurité

Secrets, entrées, dépendances, données : une passe par catégorie, avec une commande ou une recherche pour chacune.

⏱ 30 min de pratique · 5 min de quiz
[1]Scénario
Yanis · Jeudi, 20 h

Yanis veut pouvoir écrire dans son README : « Ce projet a été audité. » Mais auditer quoi, et comment ? Relire tout le code en espérant tomber sur un problème ne suffit pas. Il lui faut une méthode qu'il pourra refaire avant chaque publication.

[2]Méthode

« Secrets, entrées, dépendances, données »

  1. Secrets : chercher dans la version actuelle (git grep -n "secret\|password\|key") et dans l'historique (git log -p -S "mot cherché").
  2. Entrées : lister tout ce qui vient de l'extérieur (formulaire, adresse, fichier lu, réponse d'API) ; pour chacune, vérifier la conversion, la validation, les paramètres SQL et l'échappement.
  3. Dépendances : pip-audit compare les versions installées (ou listées) aux bases de vulnérabilités connues.
  4. Données : lister ce que le projet stocke ou envoie ; aucune donnée personnelle, une source et une licence pour chaque jeu de données.
  5. Noter l'audit : la date, les commandes, ce qui a été corrigé, dans un fichier du dépôt.

Un secret effacé reste dans l'historique (démonstration réelle, dossier jetable, identité inventée) : une clé commitée, puis remplacée par une variable d'environnement dans le commit suivant.

Dans le terminal
$ git grep -n jardin2026 (aucun résultat dans la version actuelle) $ git log -p -S jardin2026 --oneline 4e460f0 Retire le secret du code -app.secret_key = "jardin2026" 2ef5df5 Ajoute la configuration +app.secret_key = "jardin2026"

git grep ne cherche que dans la version actuelle : rien. git log -p -S cherche dans tout l'historique : la clé apparaît dans les deux commits (ajoutée +, puis retirée -). Quiconque clone le dépôt peut la lire. Il faut la changer.

Vérifier les dépendances : pip-audit. Installé dans l'environnement du projet (python -m pip install pip-audit), il interroge la base de vulnérabilités des paquets Python. Sur une liste de vieilles versions :

Dans le terminal
$ python -m pip_audit -r vieux-requirements.txt --no-deps --disable-pip
Ce que tu vois
Found 18 known vulnerabilities in 2 packages Name Version ID Fix Versions -------- ------- --------------- ------------ requests 2.19.1 PYSEC-2018-28 2.20.0 requests 2.19.1 PYSEC-2018-28 2.20.0 requests 2.19.1 PYSEC-2023-74 2.31.0 requests 2.19.1 PYSEC-2023-74 2.31.0 requests 2.19.1 PYSEC-2026-1873 2.32.0 requests 2.19.1 PYSEC-2026-1872 2.32.4 requests 2.19.1 PYSEC-2026-2275 2.33.0 requests 2.19.1 PYSEC-2026-1873 2.32.0 requests 2.19.1 PYSEC-2026-1872 2.32.4 requests 2.19.1 PYSEC-2026-2275 2.33.0 flask 0.12.2 PYSEC-2019-179 1.0 flask 0.12.2 PYSEC-2018-66 0.12.3 flask 0.12.2 PYSEC-2019-179 1.0 flask 0.12.2 PYSEC-2018-66 0.12.3 flask 0.12.2 PYSEC-2023-62 2.2.5,2.3.2 flask 0.12.2 PYSEC-2023-62 2.2.5,2.3.2 flask 0.12.2 PYSEC-2026-2151 3.1.3 flask 0.12.2 PYSEC-2026-2151 3.1.3

Chaque ligne donne le paquet, sa version, l'identifiant de la vulnérabilité et la version qui la corrige (le rapport cite certaines lignes deux fois). Sur les dépendances de « Pousse », aux versions de cet automne :

Ce que tu vois
No known vulnerabilities found

Ce résultat vaut pour un jour donné : de nouvelles vulnérabilités sont découvertes chaque mois. On relance l'audit avant chaque publication. (--no-deps --disable-pip demande de vérifier la liste telle quelle, sans installer ; la commande python -m pip_audit, sans option, audite l'environnement actif.)

Auto-diagnostic · mon projet est-il sûr ?
  • Aucun secret dans le code ni dans l'historique Git.
  • Toutes mes requêtes SQL utilisent des paramètres.
  • Aucune saisie n'est affichée sans échappement.
  • Le mode debug n'est jamais activé hors de mon ordinateur.
  • pip-audit ne signale aucune vulnérabilité connue.
  • Mon projet ne stocke ni n'envoie aucune donnée personnelle.
[3]Pourquoi ça marche

Une méthode par catégorie transforme une inquiétude vague en une liste de vérifications précises, chacune avec son outil. Les recherches automatiques (git log -S, pip-audit) voient ce qu'un œil fatigué manque : un secret enfoui dans un vieux commit, une vulnérabilité publiée la semaine dernière. Et noter l'audit permet de le refaire à l'identique avant la prochaine publication.

À retenirsecrets (version et historique), entrées (toutes), dépendances (pip-audit), données (aucune donnée personnelle) ; on note, on refait avant chaque publication.
[4]Entraînement

Bon réflexe ou piège ?

J'ai cherché le mot de passe avec git grep : rien. Le dépôt est propre.
git grep ne regarde que la version actuelle ; l'historique se fouille avec git log -p -S.
Je relance pip-audit avant chaque publication.
de nouvelles vulnérabilités sont publiées en permanence.
Un secret a été commité : je le retire du fichier et je n'en parle plus.
il reste lisible dans l'historique ; il faut le changer (le révoquer et en créer un nouveau).
Je liste toutes les entrées de mon application avant de les vérifier une par une.
on ne protège bien que ce qu'on a recensé.
pip-audit ne signale rien : mon code est sûr.
il vérifie les dépendances connues, pas ton propre code ; les autres catégories restent à faire.
[5]Quiz

1. Quelle commande retrouve un secret effacé depuis ?

-S cherche les commits où le texte apparaît ou disparaît (sortie réelle).

2. Que vérifie pip-audit ?

il compare les versions aux bases de vulnérabilités des paquets Python.

3. D'après le rapport, quelle version de Flask corrige toutes les vulnérabilités listées pour Flask 0.12.2 ?

la vulnérabilité la plus récente du rapport (30/10/2026) est corrigée en 3.1.3, la version utilisée par « Pousse ».
[6]Kit

🎒 Ton kit — Épisode 26

30 min

Exercice : l'audit de « Pousse »

  1. Secrets : git grep et git log -p -S sur les mots « secret », « password », « key ».
  2. Entrées : liste les entrées de « Pousse » (formulaire, adresse /parcelle/<nom>, cahier CSV, réponse de l'API) et note la protection de chacune.
  3. Dépendances : installe pip-audit et lance-le sur l'environnement du projet.
  4. Données : vérifie qu'aucune donnée personnelle n'est stockée ni envoyée.
  5. Écris AUDIT.md : date, commandes, résultats, corrections. Coche l'auto-diagnostic.

Code à copier : aucun ; ce sont des commandes et une liste.

Checklist

  • J'ai fouillé la version actuelle et l'historique.
  • Chaque entrée a sa protection notée.
  • pip-audit a été lancé et son résultat noté avec la date.
Vigilancen'audite que tes propres projets ; analyser le dépôt ou le site de quelqu'un d'autre sans son accord n'est pas un exercice.
[7]Journal

✍️ Journal de bord

Quelle entrée de « Pousse » avais-tu oubliée dans ta première liste ? Que se serait-il passé si elle avait été piégée ?

[8]Sources
  1. Python Packaging Authority, pip-audit (outil maintenu par la PyPA ; sources de vulnérabilités PyPI et OSV ; option -r ; code de sortie 1 si une vulnérabilité est trouvée), consulté le 30/10/2026 ; version installée : pip-audit 2.10.1.
  2. Scott Chacon et Ben Straub, Pro Git, 2e édition en français, 2.3 « Visualiser l'historique des validations » (option -S), consulté le 30/10/2026.
  3. Commandes exécutées le 30/10/2026 : pip-audit sous Python 3.15.2 (base de vulnérabilités du jour) ; démonstration Git avec Git 2.43.0 (journaux de test de ce livre).
Semaine 14 · La documentation et la publication
27
S14 · MARDI · Comprendre

Un README qui donne envie et qui suffit

Le README d'un projet (ce qu'il fait, l'installer, l'utiliser, le tester, ses données et ses limites), les docstrings, une licence, Markdown en cinq éléments.

⏱ 30 min de lecture · 5 min de quiz
[1]Scénario
Yanis · Mardi, 16 h

Le README de « Pousse » tient en une ligne : « Projet Python ». L'encadrante du laboratoire, Yanis le sait, ouvrira son dépôt une minute avant l'entretien. Elle lira peut-être seulement cette page. Une ligne, c'est tout ce qu'elle saura de quatre mois de travail.

[2]Concept

La documentation d'un projet

Le README est la page d'accueil du dépôt : les services d'hébergement l'affichent sous la liste des fichiers. Il répond, dans l'ordre, aux questions d'un visiteur : qu'est-ce que c'est ? Comment l'installer ? Comment l'utiliser ? Comment le tester ? D'où viennent les données, et quelles sont les limites ? Qui l'a fait, et sous quelle licence ?

Le README de « Pousse » (README.md) :

Fichier à copier (Markdown)
# Pousse

Pousse analyse le cahier de mesures d'un jardin partagé (la hauteur des radis de deux parcelles, l'une paillée, l'autre nue) et le croise avec la météo de la ville. Il affiche le résultat dans une petite application web qui tourne sur ton ordinateur.

![Hauteur des radis par semaine et par parcelle](docs/figure.png)

## Ce que fait Pousse

- lit le cahier de mesures (`donnees/mesures-jardin.csv`) et signale les lignes écartées ;
- calcule la hauteur finale et la croissance moyenne de chaque parcelle ;
- récupère la température moyenne quotidienne par l'API Open-Meteo, une seule fois, puis la garde en cache ;
- range mesures et météo dans une base SQLite et croise chaque mesure avec la température des 7 jours précédents ;
- affiche une page web locale : la figure, le tableau de chaque parcelle, un formulaire d'ajout.

## Installation

Avec Python 3.12 ou plus récent :

python -m venv .venv source .venv/bin/activate # sous Windows : .venv\Scripts\activate python -m pip install -e ".[dev]"

Ce que tu vois
## Utilisation

python -m pousse # résumé dans le terminal python essais/essai_cache.py # télécharge la météo une fois (cache : meteo-cache.json) python essais/charger_base.py # remplit la base pousse.db flask --app pousse.app run --port 5050 # puis ouvrir http://127.0.0.1:5050

Ce que tu vois
## Tests

python -m pytest python -m mypy src tests

Ce que tu vois
Les tests n'utilisent pas le réseau : ils lisent une réponse d'exemple (`tests/reponse-exemple-meteo.json`, valeurs inventées). ## Structure - `src/pousse/` : le paquet (`lecture`, `modele`, `analyse`, `meteo`, `stockage`, `app`, gabarits HTML) ; - `tests/` : les tests pytest ; - `essais/` : petits programmes d'essai, dont un faux serveur pour travailler sans réseau ; - `donnees/` : le cahier de mesures. ## Données et limites - Le cahier de mesures est un jeu de données inventé pour ce projet. - Météo : [Open-Meteo.com](https://open-meteo.com/), données sous licence CC BY 4.0 ; usage non commercial, sans clé. - Deux parcelles et une saison ne permettent aucune conclusion générale sur le paillage : Pousse décrit, il ne prouve pas. - L'application est un serveur de développement, à lancer seulement sur son ordinateur ; le mode debug ne doit jamais être activé ailleurs. ## Auteur et licence Yanis (projet personnel, L1 sciences de la vie). Code sous licence MIT (voir `LICENSE`).

Markdown en cinq éléments (le format .md, affiché mis en forme par les services d'hébergement) :

ÉcritureRésultat
# Titre, ## Sous-titredes titres
- élémentune liste à puces
` code ` et un bloc entre trois accents gravesdu code
[texte](adresse)un lien
![description](chemin/image.png)une image (ici, la figure du jardin, dans docs/)

Les docstrings documentent le code lui-même (Pro, épisode 1) ; le README documente le projet ; un guide d'utilisation plus long irait dans un dossier docs/.

La licence. Sans licence, personne n'a le droit de réutiliser ton code, même s'il est public. Une licence dit ce que les autres peuvent en faire. La licence MIT, courte et permissive, autorise tout usage (y compris commercial), la modification et la redistribution, à une condition : conserver l'avis de droit d'auteur et le texte de la licence ; elle exclut toute garantie. Le fichier LICENSE de « Pousse » contient le texte officiel de la MIT, avec Copyright (c) 2026 Yanis. D'autres licences imposent que les versions modifiées restent libres : le site choosealicense.com aide à choisir.

La licence des données n'est pas celle du code : la météo d'Open-Meteo est sous CC BY 4.0, d'où la mention de la source dans le README et dans l'application (épisode 19).

Les limites, écrites noir sur blanc. Une section « Données et limites » honnête rassure plus qu'elle n'inquiète : le jeu de données est inventé, deux parcelles ne prouvent rien, l'application n'est pas faite pour être mise en ligne.

Générateur · squelette de README
Texte obtenu

Pas d'adresse, de téléphone ni d'adresse électronique personnelle dans un README public.

[3]Recherche

Ce que disent les sources

  • Documentation de GitHub, « À propos des README » — Un README dit ce que fait le projet, pourquoi il est utile, comment commencer à l'utiliser, où obtenir de l'aide, et qui le maintient ; une documentation plus longue va ailleurs (un wiki) [1].
  • choosealicense.com, « No license » — Sans licence, le code est protégé par le droit d'auteur exclusif par défaut : personne ne peut le copier, le distribuer ou le modifier ; pouvoir le voir ou le dupliquer sur un service d'hébergement n'accorde pas ces droits [2].
  • choosealicense.com, licence MIT — Permissions : usage commercial, distribution, modification, usage privé ; condition : conserver les mentions de droit d'auteur et de licence ; limitations : aucune responsabilité, aucune garantie [3].
  • Open-Meteo — Données sous licence CC BY 4.0, réutilisables en citant la source [4].
[4]Mythes
« Le code se documente tout seul. »
un code clair dit comment ; seul le README dit pourquoi le projet existe, comment l'installer et ce qu'il ne fait pas.
« Sans licence, mon code est libre. »
c'est l'inverse : sans licence, personne n'a le droit de le réutiliser.
« Avouer les limites de son projet, c'est se tirer une balle dans le pied. »
une encadrante de laboratoire attend exactement cela : savoir ce que tes résultats montrent, et ce qu'ils ne montrent pas.
[5]Quiz

1. Que se passe-t-il pour un dépôt public sans licence ?

le droit d'auteur exclusif s'applique par défaut (choosealicense.com).

2. Quelle condition impose la licence MIT ?

c'est sa seule condition ; elle exclut aussi toute garantie.

3. Pourquoi le README de « Pousse » cite-t-il Open-Meteo ?

BY = attribution : on cite la source des données.
[6]Kit

🎒 Ton kit — Épisode 27

30 min

Exercice : le README de « Pousse »

  1. Remplis le générateur de README, puis complète le résultat dans README.md.
  2. Ajoute la figure du jardin dans docs/figure.png et affiche-la dans le README.
  3. Choisis une licence sur choosealicense.com et ajoute le fichier LICENSE (texte officiel, ton nom ou ton pseudo, l'année).
  4. Relis ta section « Données et limites » : qu'est-ce que ton projet ne prouve pas ?

Code à copier : le README de « Pousse », comme modèle à adapter.

Checklist

  • Mon README dit ce que fait le projet, comment l'installer, l'utiliser et le tester.
  • Il cite la source et la licence des données, et les limites du projet.
  • Le dépôt contient un fichier LICENSE.
Vigilancen'écris dans le README ni ton adresse, ni ton téléphone, ni ton adresse électronique personnelle ; un pseudo ou ton prénom suffisent (et, pour un mineur, en accord avec un adulte).
[7]Journal

✍️ Journal de bord

Si l'encadrante ne lisait que les trois premières lignes de ton README, que saurait-elle de toi et de ton projet ?

[8]Sources
  1. GitHub, À propos des README, consulté le 30/10/2026.
  2. choosealicense.com, No License, consulté le 30/10/2026.
  3. choosealicense.com, Licence MIT, consulté le 30/10/2026.
  4. Open-Meteo, Free Weather API (licence CC BY 4.0), consulté le 30/10/2026.
28
S14 · JEUDI · Pratiquer

Publier le dépôt

Le test du clone neuf : un dossier vide, le README et rien d'autre ; chaque blocage devient une correction ; puis la publication.

⏱ 30 min de pratique · 5 min de quiz
[1]Scénario
Yanis · Jeudi, 17 h

Yanis tend son ordinateur à Awa : « Installe et lance “Pousse”. Tu n'as le droit de lire que le README. Et tu ne me poses aucune question. » Elle ouvre un terminal. Il croise les bras et se tait. C'est le plus difficile.

[2]Méthode

« Le test du clone neuf »

  1. Un dossier vide, sur un autre ordinateur ou dans un autre compte : pas de fichier oublié, pas d'environnement déjà prêt.
  2. Suivre le README à la lettre, commande par commande : git clone, environnement, installation, tests, lancement.
  3. Noter chaque blocage (commande absente, étape implicite, fichier manquant) sans le corriger sur place.
  4. Corriger le README ou le dépôt, commit, et recommencer jusqu'à zéro blocage.
  5. Publier : rendre le dépôt public (ou privé et partagé avec l'encadrante) ; pour un mineur, avec un adulte (Pro, épisode 18).

Le .gitignore final de « Pousse » :

Contenu de .gitignore
.venv/ __pycache__/ *.egg-info/ .env pousse.db meteo-cache.json

La base pousse.db et le cache météo se recréent avec les commandes du README ; .env contiendrait d'éventuels secrets ; *.egg-info/ est produit par l'installation.

Le test du clone neuf, en vrai (dépôt local, nouvel environnement, installation depuis PyPI ; identité inventée) :

Dans le terminal
fichiers suivis : 30 $ git clone depot copie-awa $ python -m venv .venv $ python -m pip install -e ".[dev]" (installation terminée : Version: 0.1.0) $ python -m pytest -q ........................ [100%] 24 passed in 6.94s $ python -m pousse Parcelle A (paillée) : 11 mesures, hauteur finale 74.5 mm, croissance moyenne 6.0 mm/semaine Parcelle B (nue) : 11 mesures, hauteur finale 59.5 mm, croissance moyenne 4.75 mm/semaine 2 lignes écartées. $ git status --short (rien : la base, le cache et .venv sont ignorés)

30 fichiers suivis, une installation en une commande, 24 tests au vert, le programme qui affiche le résumé, et git status vide : rien de ce qui a été produit pendant l'essai ne risque d'être commité par erreur.

Auto-diagnostic · mon dépôt est-il prêt ?
  • Quelqu'un a installé et lancé le projet avec le seul README.
  • Tous les tests passent depuis un clone neuf.
  • Le dépôt contient README.md, LICENSE et .gitignore.
  • Aucun fichier produit (base, cache, environnement) n'est suivi par Git.
  • L'audit de sécurité est fait et daté.
  • Les conditions d'âge du service d'hébergement sont respectées (avec un adulte pour un mineur).
[3]Pourquoi ça marche

Ton ordinateur te ment par omission : l'environnement est déjà prêt, la base existe, une variable traîne. Un dossier vide et un lecteur qui ne pose pas de questions révèlent tout ce que le README suppose sans le dire. Chaque blocage corrigé, c'est un recruteur qui n'abandonnera pas à la troisième commande.

À retenirun dossier vide, le README seul, chaque blocage noté puis corrigé, et on recommence jusqu'à zéro.
[4]Entraînement

Bon réflexe ou piège ?

Je fais le test du clone dans mon dossier de travail, c'est plus rapide.
ton environnement et tes fichiers locaux cachent les oublis.
Pendant le test, Awa me pose une question ; j'y réponds à voix haute.
la réponse doit aller dans le README, sinon le prochain lecteur sera bloqué.
Je vérifie avec git status que la base et le cache ne sont pas suivis.
un fichier produit n'a rien à faire dans le dépôt.
Je publie le dépôt avant d'avoir fait l'audit de sécurité.
une fois public, tout est lu, historique compris.
Je recommence le test après chaque correction du README.
une correction peut en révéler une autre.
[5]Quiz

1. Pourquoi Awa ne doit-elle poser aucune question pendant le test ?

chaque question révèle une information absente du README.

2. Après le test, git status --short n'affiche rien. Que cela signifie-t-il ?

.venv/, pousse.db et *.egg-info/ sont dans .gitignore (sortie réelle).

3. Un lycéen de 16 ans veut publier son dépôt sur GitHub. Que faut-il ?

les conditions d'âge (Pro, épisode 18) et la règle du livre pour les mineurs (avec un adulte).
[6]Kit

🎒 Ton kit — Épisode 28

30 min

Exercice : publier « Pousse »

  1. Demande à quelqu'un de faire le test du clone neuf avec ton README, sans poser de question.
  2. Note chaque blocage, corrige, recommence.
  3. Vérifie git status après l'essai.
  4. Coche l'auto-diagnostic, puis publie (public, ou privé partagé ; pour un mineur, avec un adulte).

Code à copier : le .gitignore de « Pousse ».

Checklist

  • Zéro blocage au dernier test du clone neuf.
  • README.md, LICENSE et .gitignore sont dans le dépôt.
  • L'audit de sécurité est fait et daté.
Vigilanceavant de rendre le dépôt public, relis tout l'historique des commits : un nom réel, une adresse, une donnée personnelle glissés un jour y sont encore.
[7]Journal

✍️ Journal de bord

Quel blocage du test t'a le plus surpris ? Qu'est-ce qu'il t'apprend sur ce que tu « sais sans le savoir » ?

[8]Sources
  1. Scott Chacon et Ben Straub, Pro Git, 2e édition en français, 2.2 (.gitignore) et 2.5 (git clone), consulté le 30/10/2026.
  2. GitHub, GitHub Terms of Service (13 ans minimum, en vigueur le 27 avril 2026), consulté le 30/10/2026.
  3. Test du clone neuf exécuté le 30/10/2026 sous Python 3.12.13, 3.15.0 et 3.15.2 : dépôt local, environnement neuf, installation depuis PyPI (journaux de test de ce livre).
Semaine 15 · Apprendre seul et l'entretien
29
S15 · MARDI · Comprendre

Apprendre seul, préparer l'entretien

Apprendre sans cours (documentation, notes de version, exemple minimal, IA en tuteur) et préparer un entretien technique de stage.

⏱ 30 min de lecture · 5 min de quiz
[1]Scénario
Yanis · Mardi, 21 h

L'entretien est dans trois semaines. Yanis a lu l'annonce dix fois : « Python, pandas, Git appréciés ; d'autres outils seront appris sur place. » D'autres outils ? Et si on lui demandait une bibliothèque qu'il n'a jamais vue ? Il imagine déjà le silence.

[2]Concept

Apprendre seul, et le montrer

1. Apprendre seul : une méthode. Personne ne connaît tout ; ce qu'on attend d'un stagiaire, c'est de savoir apprendre vite et proprement.

  • La documentation officielle d'abord, celle de ta version (Pro, épisode 12) : le tutoriel pour démarrer, la référence pour les détails.
  • Les notes de version (What's New, changelog) disent ce qui a changé. Exemple : celles de Python 3.15 annoncent notamment un type intégré frozendict (PEP 814), un dictionnaire qu'on ne peut plus modifier. Un code qui l'utilise suppose donc Python 3.15 :
Code
import sys

print(sys.version_info[:2])
parcelles = frozendict({"A": "paillée", "B": "nue"})
print(parcelles["A"])

Sous Python 3.15 (sortie réelle) :

Ce que tu vois
(3, 15) paillée

Sous Python 3.12 (sortie réelle) :

Ce que tu vois
(3, 12) Traceback (most recent call last): File "frozendict_315.py", line 4, in <module> parcelles = frozendict({"A": "paillée", "B": "nue"}) NameError: name 'frozendict' is not defined. Did you mean: 'frozenset'?

Lire les notes de version évite de chercher pendant une heure pourquoi un exemple trouvé en ligne « ne marche pas chez toi ». « Pousse » demande Python 3.12 ou plus (requires-python) : il n'utilise donc pas frozendict.

  • L'exemple minimal : réduire un problème au plus petit code qui le montre (Pro, épisode 4) ; souvent, on trouve la réponse en le construisant.
  • Les forums : chercher d'abord si la question a déjà été posée ; sinon, poser une question avec l'exemple minimal, ce qu'on attendait, ce qu'on obtient, ce qu'on a essayé, en respectant les règles du forum.
  • L'IA en tuteur, pas en auteur : lui demander d'expliquer un message d'erreur, de te poser des questions sur une notion, de comparer deux approches ; relire, tester, déclarer (Pro, épisode 19), dans le respect des conditions d'âge et des règles de ton établissement.

2. L'entretien technique de stage. Il vérifie trois choses : que tu as vraiment fait ce que tu présentes, que tu sais expliquer tes choix, et comment tu raisonnes devant un problème nouveau.

  • Présenter son projet en 5 minutes : le problème, la démonstration, deux choix techniques expliqués, une limite, une suite possible.
  • Expliquer un choix : « pourquoi SQLite ? », « pourquoi une dichotomie ? » (épisodes 3 et 21) ; donner la raison et l'alternative écartée.
  • Raisonner à voix haute devant un petit problème : reformuler, prendre un exemple, proposer une idée simple, coder, tester, estimer le coût (épisode 7).
  • Dire « je ne sais pas », puis : « voici comment je chercherais » (la méthode ci-dessus). C'est une réponse honnête et appréciée ; inventer, non.
  • Préparer ses questions pour la fin de l'entretien : sur l'équipe, les données, les outils.

Ce livre ne promet pas de stage : il t'aide à arriver préparé, avec un projet que tu comprends de bout en bout.

[3]Recherche

Ce que disent les sources

  • Documentation officielle de Python 3.15, « What's new in Python 3.15 » — Parmi les nouveautés : les imports paresseux explicites (PEP 810), le type intégré frozendict (PEP 814), le type intégré sentinel (PEP 661), un paquet dédié au profilage (PEP 799) [1].
  • Tests de ce livre — frozendict fonctionne sous Python 3.15.0 et 3.15.2 ; sous Python 3.12.13, il lève NameError (journaux de test) [2].
  • Université Toulouse 3, SCUIO-IP, atelier « Préparer son entretien de recrutement » — Les services d'orientation et d'insertion professionnelle des universités proposent de préparer l'entretien : ses objectifs, l'avant, le pendant et l'après, les questions récurrentes, avec une simulation d'entretien [3].
[4]Mythes
« Un bon développeur connaît tout par cœur. »
il sait où chercher, et il lit la documentation de la bonne version.
« En entretien, il ne faut jamais avouer qu'on ne sait pas. »
« je ne sais pas, voici comment je chercherais » montre exactement ce qu'on attend d'un stagiaire.
« Avec une IA, plus besoin d'apprendre. »
en entretien, c'est toi qui expliques ton code ; une IA utilisée en tuteur t'y prépare, utilisée en auteur, elle t'y piège.
[5]Quiz

1. Un exemple trouvé en ligne utilise frozendict et échoue chez toi avec NameError. Première vérification ?

frozendict est une nouveauté de Python 3.15 (sortie réelle sous 3.12 : NameError).

2. On te demande en entretien une bibliothèque que tu ne connais pas. Meilleure réponse ?

l'honnêteté et une méthode valent mieux qu'une réponse inventée.

3. Quel usage de l'IA t'aide à préparer l'entretien ?

en tuteur, elle t'entraîne ; c'est toi qui dois pouvoir expliquer.
[6]Kit

🎒 Ton kit — Épisode 29

30 min

Exercice : préparer l'entretien

  1. Lis les nouveautés de la dernière version de Python dans les notes de version officielles et note-en deux, avec leur PEP.
  2. Écris tes réponses aux 5 questions probables : pourquoi SQLite ? Quel est le coût de ta recherche de dates ? Comment sais-tu que ton code marche ? Qu'as-tu fait pour la sécurité ? Que ferais-tu ensuite ?
  3. Prépare ta présentation de 5 minutes (problème, démonstration, deux choix, une limite, une suite).
  4. Prépare trois questions à poser à la fin.

Code à copier : aucun ; ce sont tes réponses et ta présentation.

Checklist

  • Je sais présenter « Pousse » en 5 minutes.
  • J'ai une réponse préparée pour chacune des 5 questions.
  • Je sais dire « je ne sais pas, voici comment je chercherais ».
Vigilanceen entretien, ne présente comme tien que ce que tu as fait et compris ; dis ce qui vient d'ailleurs (un tutoriel, une aide d'IA déclarée, Pro, épisode 19).
[7]Journal

✍️ Journal de bord

Quelle question d'entretien te fait le plus peur ? Écris ta réponse, puis la façon dont tu chercherais si tu ne savais vraiment pas.

[8]Sources
  1. Python Software Foundation, What's new in Python 3.15, sommaire de la documentation de Python 3.15, consulté le 30/10/2026.
  2. Programme exécuté le 30/10/2026 sous Python 3.12.13, 3.15.0 et 3.15.2 (journaux de test de ce livre).
  3. Université Toulouse 3 – Paul Sabatier, SCUIO-IP, Préparer son entretien de recrutement, fiche d'atelier, consulté le 30/10/2026.
30
S15 · JEUDI · Pratiquer

L'entretien blanc

Présenter, expliquer un choix, résoudre un petit problème à voix haute : énoncé, exemple, idée, code, test, coût.

⏱ 35 min de pratique · 5 min de quiz
[1]Scénario
Yanis · Jeudi, 18 h

Awa joue l'encadrante. Elle a préparé un petit problème sur une feuille et chronomètre. Après la présentation de « Pousse », elle pose la feuille sur la table : « Dans ta parcelle A, quelle est la plus longue série de semaines consécutives où la croissance a dépassé 6 mm par semaine ? Tu as vingt minutes. Pense à voix haute. »

[2]Méthode

« Présenter, expliquer, résoudre à voix haute »

  1. Présenter (5 min) : le problème, la démonstration de l'application, deux choix techniques, une limite, une suite.
  2. Répondre aux questions (5 min) : la raison du choix, l'alternative écartée ; « je ne sais pas, voici comment je chercherais ».
  3. Résoudre à voix haute (10 à 20 min) :
  • reformuler l'énoncé et ce qu'on renvoie (« une longueur, un entier ») ;
  • un exemple à la main : [7, 8, 5, 9] avec un seuil de 6 → 2 ;
  • les cas limites : liste vide, aucune valeur au-dessus du seuil ;
  • une idée simple : parcourir en comptant la série en cours, la remettre à zéro quand elle s'interrompt, garder la meilleure ;
  • coder, puis tester sur l'exemple et les cas limites ;
  • estimer le coût : un seul parcours, O(n).

La solution d'Yanis, telle qu'il l'a écrite et testée pendant l'entretien blanc :

Code
def plus_longue_serie(croissances, seuil):
    """Renvoie la longueur de la plus longue suite de valeurs consécutives strictement supérieures à seuil."""
    meilleure = 0
    courante = 0
    for valeur in croissances:
        if valeur > seuil:
            courante = courante + 1
            meilleure = max(meilleure, courante)
        else:
            courante = 0
    return meilleure


assert plus_longue_serie([], 6.0) == 0
assert plus_longue_serie([5.0, 4.0], 6.0) == 0
assert plus_longue_serie([7.0, 8.0, 5.0, 9.0], 6.0) == 2
assert plus_longue_serie([7.0, 8.0, 9.0], 6.0) == 3
croissances_a = [6.5, 7.5, 9.5, 8.5, 7.5, 6.5, 5.5, 4.5, 2.5, 1.5]
print(plus_longue_serie(croissances_a, 6.0))
print(plus_longue_serie(croissances_a, 8.0))

Sortie réelle :

Ce que tu vois
6 2

Les assert passent (aucun message) ; les croissances de la parcelle A (épisode 14) donnent une série de 6 semaines au-dessus de 6 mm, et de 2 au-dessus de 8 mm. Coût : un seul parcours de la liste, O(n), et deux variables en mémoire.

Auto-diagnostic · suis-je prêt pour l'entretien ?
  • Je présente « Pousse » en 5 minutes, démonstration comprise.
  • J'explique deux choix techniques et l'alternative écartée.
  • Je reformule un problème avant de coder.
  • Je teste mon code sur un exemple et des cas limites.
  • J'estime le coût de ma solution.
  • Je sais dire « je ne sais pas, voici comment je chercherais ».
[3]Pourquoi ça marche

Un entretien technique évalue un raisonnement plus qu'un résultat : penser à voix haute le rend visible. La méthode (reformuler, exemple, cas limites, idée simple, code, test, coût) évite les deux pièges du stress : se taire et coder trop vite. Et l'entretien blanc, chronométré, avec quelqu'un qui joue le jeu, transforme l'inconnu en situation déjà vécue.

À retenirreformuler, un exemple à la main, les cas limites, une idée simple, coder, tester, estimer le coût, à voix haute.
[4]Entraînement

Bon réflexe ou piège ?

Je commence à coder dès que j'ai lu l'énoncé, pour gagner du temps.
sans reformulation ni exemple, tu risques de résoudre un autre problème.
Je prends un exemple à la main avant d'écrire du code.
il servira aussi de premier test.
Je me tais pour me concentrer.
l'interlocuteur ne voit pas ton raisonnement ; dis ce que tu penses, même les hésitations.
Je teste le cas de la liste vide.
les cas limites sont ce qu'on vérifie en premier en entretien.
Je propose d'emblée la solution la plus astucieuse, même si je ne suis pas sûr qu'elle marche.
une solution simple et juste vaut mieux ; on l'améliore ensuite si on a le temps.
[5]Quiz

1. Que renvoie plus_longue_serie([7.0, 8.0, 5.0, 9.0], 6.0) ?

7 et 8 forment une série de 2 ; 5 l'interrompt ; 9 forme une série de 1 (assert réel).

2. Quel est le coût de plus_longue_serie ?

un seul parcours de la liste.

3. Quelle étape vient juste après la reformulation de l'énoncé ?

l'exemple vérifie qu'on a compris, et devient le premier test.
[6]Kit

🎒 Ton kit — Épisode 30

35 min

Exercice : l'entretien blanc

  1. Avec quelqu'un qui joue l'encadrant : présentation de 5 minutes, chronométrée.
  2. Questions sur tes choix, avec tes réponses de l'épisode 29.
  3. Le petit problème, à voix haute, sans regarder la solution ; puis compare avec celle d'Yanis.
  4. Variante pour un second entretien blanc : renvoyer aussi la semaine de début de la plus longue série.
  5. Coche l'auto-diagnostic.

Code à copier : serie.py, après avoir essayé seul.

Checklist

  • J'ai fait au moins un entretien blanc chronométré.
  • J'ai résolu le petit problème à voix haute, avec tests et coût.
  • J'ai noté ce que je dois améliorer.
Vigilanceen entretien blanc comme en vrai, garde ton projet personnel et ton dépôt à jour : on te demandera peut-être de montrer un commit précis.
[7]Journal

✍️ Journal de bord

Qu'as-tu oublié de dire à voix haute pendant l'entretien blanc ? Qu'est-ce qu'Awa (ou ton partenaire) a remarqué que tu n'avais pas vu ?

[8]Sources
  1. Programme exécuté le 30/10/2026 sous Python 3.12.13, 3.15.0 et 3.15.2 (journaux de test de ce livre).
  2. Université Toulouse 3 – Paul Sabatier, SCUIO-IP, Préparer son entretien de recrutement (simulation d'entretien), consulté le 30/10/2026.
Semaine 16 · Expliquer et transmettre
31
S16 · MARDI · Comprendre

Expliquer son code, aider ses pairs

Expliquer un programme du « quoi » au « comment » ; aider en tutorat : faire reformuler, questionner, donner un indice, laisser le clavier.

⏱ 30 min de lecture · 5 min de quiz
[1]Scénario
Yanis · Mardi, 13 h

Yanis est devenu tuteur pour les L1 qui débutent en Python. Premier créneau : un étudiant tourne son écran vers lui. « Ça marche pas. » La fonction renvoie 1 au lieu de 10. Yanis voit le problème en deux secondes : le return est dans la boucle. Sa main part déjà vers le clavier.

[2]Concept

Expliquer et aider

1. Expliquer un programme. Du plus général au plus précis :

  • le quoi : ce que fait le programme, en une phrase, sans mot technique (« Pousse compare la croissance de deux parcelles de radis selon la météo ») ;
  • la démonstration : montrer avant d'expliquer ;
  • le comment : les grandes pièces et leurs liens, avec un schéma (les six modules de « Pousse » et leurs flèches : lecture → modele → analyse ; meteo et stockage ; app qui appelle les autres) ;
  • un détail choisi : un seul morceau de code, expliqué à fond (la requête des sept jours, épisode 22) ;
  • adapter les mots au public : une encadrante de laboratoire, un camarade de L1, un parent.

2. Aider sans faire à la place : le tutorat. Le but n'est pas que le programme marche, mais que l'étudiant sache le réparer la prochaine fois.

  • Faire reformuler : « Qu'est-ce que la fonction devrait renvoyer ? Qu'est-ce qu'elle renvoie ? »
  • Demander ce qui a été essayé, et montrer la méthode plutôt que la réponse : lire l'erreur en 3 questions (Boost, épisode 2), afficher les valeurs, réduire (Pro, épisode 4).
  • Donner un indice gradué : d'abord une question (« Combien de tours fait ta boucle ? »), puis un indice (« Ajoute un print dans la boucle »), et seulement en dernier recours la réponse, expliquée.
  • Laisser le clavier : c'est l'étudiant qui tape ; le tuteur parle et montre du doigt.
  • Respecter l'intégrité : aider à comprendre, jamais faire le TP de quelqu'un d'autre ; renvoyer vers l'enseignant ce qui relève de l'évaluation.
  • Encourager : un bug n'est pas un échec, c'est l'ordinaire de la programmation.

Le premier créneau d'Yanis, refait avec la méthode : « Qu'est-ce que tu attends pour somme_jusqua(4) ? — 10. — Et tu obtiens ? — 1. — Combien de tours fait ta boucle, à ton avis ? — Quatre. — Ajoute un print(i) dans la boucle et relance. » L'étudiant voit un seul 1. Silence. « Ah. Le return est dans la boucle. » Il le décale lui-même. C'est le bug de l'épisode 4 du Pro (Pro, épisode 4) : Yanis l'a reconnu ; l'étudiant, lui, l'a trouvé.

Générateur · déroulé d'une séance de tutorat (30 min)
Texte obtenu
[3]Recherche

Ce que disent les sources

  • Université de Lille, DAPI, livret du tutorat (édition 2026) — Le tutorat développe des stratégies d'apprentissage et d'autonomie ; il ne se substitue pas aux enseignements de l'équipe pédagogique ; les tuteurs analysent les besoins d'accompagnement et apprennent à « re-expliquer » plutôt qu'à « restituer » [1].
  • Université de Montpellier, charte du tutorat — Le tuteur apporte une aide méthodologique et pédagogique sur des exercices validés par les enseignants ; le tutorat « n'a pas vocation à se substituer à l'enseignement dispensé en cours, travaux dirigés ou travaux pratiques », ni au travail personnel de l'étudiant [2].
[4]Mythes
« Aider, c'est donner la solution. »
donner la solution règle le bug d'aujourd'hui ; faire trouver règle ceux de demain.
« Si je dois expliquer mon code, c'est qu'il est mal écrit. »
même un code limpide a besoin du « pourquoi » : le problème, les choix, les limites.
« Expliquer, c'est pour les profs. »
en stage, en équipe, en entretien, tu passeras autant de temps à expliquer qu'à coder ; et expliquer est la meilleure façon de vérifier qu'on a compris.
[5]Quiz

1. Un étudiant te montre un bug que tu as reconnu immédiatement. Première chose à faire ?

faire reformuler, c'est souvent la moitié du chemin vers la solution.

2. Dans quel ordre expliquer « Pousse » à quelqu'un qui ne le connaît pas ?

du général au précis, et montrer avant d'expliquer.

3. Un camarade te demande de faire son TP noté « parce que tu sais ». Que fais-tu ?

l'intégrité académique vaut pour le tuteur comme pour l'étudiant.
[6]Kit

🎒 Ton kit — Épisode 31

30 min

Exercice : une séance de tutorat

  1. Remplis le générateur pour une séance de 30 minutes sur les fonctions, pour des L1.
  2. Prépare trois questions graduées pour le bug du return dans la boucle.
  3. Dessine le schéma des modules de « Pousse » et explique-le en 2 minutes à quelqu'un qui ne code pas.
  4. Si tu en as l'occasion, anime la séance pour de vrai ; sinon, joue-la avec Awa (ou un camarade).

Code à copier : aucun ; c'est un déroulé et un schéma.

Checklist

  • J'explique « Pousse » du quoi au comment, avec un schéma.
  • Mon déroulé laisse le clavier aux étudiants.
  • J'ai préparé des indices gradués plutôt qu'une réponse.
Vigilanceen tutorat, ne demande ni ne copie les identifiants, le travail noté ou les données personnelles d'un étudiant ; aide-le à travailler sur son propre compte, à son poste.
[7]Journal

✍️ Journal de bord

Quand quelqu'un t'a-t-il aidé sans faire à ta place ? Qu'a-t-il fait exactement, et que reprendras-tu comme tuteur ?

[8]Sources
  1. Université de Lille, DAPI, Livret du tutorat, édition 2026, consulté le 30/10/2026.
  2. Université de Montpellier, Charte du tutorat, présentée à la CFVU du 17 octobre 2019, consulté le 30/10/2026.
32
S16 · JEUDI · Pratiquer · Final

Ton portfolio, ta boîte à outils et ton certificat

Présenter le portfolio, rassembler tes outils, puis célébrer la fin du Master.

⏱ 30 min · boîte à outils · carrousel · certificat
[1]Scénario
Yanis · Jeudi, 10 h

L'entretien au laboratoire. Yanis partage son écran : la page de « Pousse », la figure du jardin, les tests au vert, le README. L'encadrante clique dans l'historique des commits, s'arrête sur l'audit de sécurité, puis demande : « Pourquoi une dichotomie pour les dates ? » Il répond en trente secondes, coût compris. Puis : « Et ce que vos données ne montrent pas ? » Il sourit : c'est écrit dans le README. L'après-midi, il anime sa séance de tutorat ; l'étudiant de la semaine dernière trouve seul son return mal placé. Le livre ne dira pas si Yanis a obtenu le stage : il dit qu'il y est arrivé prêt.

[2]Outil

Ma boîte à outils

Consigne : vérifie une dernière fois le test du clone neuf, publie ton dépôt (avec un adulte pour un mineur), puis range tes outils du Master dans ta boîte à outils.

Le portfolio publié : le dépôt de « Pousse » (30 fichiers, README.md, LICENSE, .gitignore, 24 tests, audit daté), la figure du jardin, la présentation de 5 minutes, et la séance de tutorat préparée.

Boîte à outils interactive · Enregistrée dans ce navigateur
[3]Carrousel final

🎨 Carrousel final — Master

[4]Journal

✍️ Journal de bord — mot de la fin

Relis ta réponse au journal du premier épisode. Qu'est-ce qui a le plus changé dans ta façon de concevoir un projet ? Quel sera ton prochain projet ?

✦ DÉVELOPPEUSE · DÉVELOPPEUR ET MENTOR · ARC 4 ✦

Bravo ! Tu as terminé l'arc 4. Tu sais sécuriser, documenter et publier un projet, apprendre seul, préparer un entretien technique et aider les autres à progresser.

[5]Certificat

🎓 Ton certificat

Le certificat s'affiche lorsque les 32 épisodes sont marqués comme terminés.

COLLECTION « CHALLENGES » · APPRENDRE À CODER EN PYTHON

Certificat de réussite

Ce certificat atteste que

—

a terminé les épisodes du Master — Mon projet portfolio et atteint le niveau Développeuse · Développeur et mentor.

Ce que ce parcours couvre : compréhensions, recherche, tri et dichotomie, récursivité, complexité, exceptions, classes et dataclasses, tests automatisés avec pytest, annotations de type et mypy, paquet et pyproject.toml, API web, SQLite, application Flask, sécurité, README et licence, apprendre seul et entretien technique, tutorat.

[6]Sources
  1. Test du clone neuf, tests et audit exécutés le 30/10/2026 sous Python 3.12.13, 3.15.0 et 3.15.2 (journaux de test de ce livre).
Annexe · Kit de rattrapage et glossaire
ARattrapage

Tu démarres le Master sans avoir suivi le Boost et le Pro ? Voici, en une ligne chacune, les notions supposées acquises.

  • Variables, types et calculs — = range une valeur ; int, float, str, bool ; conversions avec int(), float() (Boost, épisode 1).
  • Lancer un script et lire une erreur — quelle ligne, quel type, que dit le message (Boost, épisode 2).
  • Conditions et boucles — if / elif / else, for avec range, while, l'accumulateur (Boost, épisode 3).
  • Construire pas à pas — une étape, un test, un cas calculé à la main (Boost, épisode 4).
  • Fonctions — def, paramètres, return, variables locales, docstring (Pro, épisode 1).
  • Vérifier une fonction — un assert par cas, aux seuils et juste en dessous (Pro, épisode 2).
  • Texte et f-strings — tranches, strip, lower, replace, f"{x:.2f}" (Pro, épisode 3).
  • Déboguer — reproduire, afficher, réduire, corriger une chose, retester (Pro, épisode 4).
  • Listes — indices, append, parcours, sorted et .sort() (Pro, épisode 6).
  • Dictionnaires, tuples, ensembles — retrouver par une clé, grouper des valeurs, éliminer les doublons (Pro, épisode 8).
  • Code lisible — noms clairs, PEP 8, docstrings, constantes (Pro, épisode 9).
  • Bibliothèque standard et documentation — import, statistics, datetime, lire la fiche d'une fonction (Pro, épisodes 11 et 12 — Pro, épisode 11).
  • Fichiers CSV et JSON — pathlib, with open, csv.DictReader, json.dump (Pro, épisode 13).
  • Environnement virtuel — python -m venv .venv, activer, python -m pip install, requirements.txt (Pro, épisode 14).
  • pandas et matplotlib — read_csv, groupby, une figure avec titre, axes et légende (Pro, épisodes 16 et 17 — Pro, épisode 16).
  • Git — add, commit, log, .gitignore, dépôt distant (Pro, épisode 18).
  • Coder avec une IA — vérifier son droit, demander, relire, tester, déclarer (Pro, épisode 19).
BGlossaire
Compréhension
expression qui construit une liste, un dictionnaire ou un ensemble à partir d'une séquence, en une ligne.
Recherche séquentielle
recherche qui examine les éléments un par un, dans l'ordre.
Dichotomie
recherche dans une liste triée qui élimine la moitié des éléments restants à chaque étape.
Tri stable
tri qui garde l'ordre de départ des éléments égaux.
Récursivité
fait, pour une fonction, de s'appeler elle-même sur un problème plus petit.
Cas de base
situation où une fonction récursive renvoie sa réponse sans s'appeler.
Pile d'appels
ensemble des appels de fonction en cours, le dernier commencé étant le premier terminé.
Complexité
façon dont le nombre d'opérations d'un algorithme grandit avec la taille des données, notée O(…).
Exception
signal levé par Python quand une instruction ne peut pas s'exécuter ; elle s'intercepte avec try / except.
Lever une exception
la déclencher avec raise, avec un message qui explique le problème.
Classe
modèle qui définit les attributs et les méthodes d'un type d'objet.
Objet
exemplaire construit sur le modèle d'une classe (aussi appelé instance).
Attribut
donnée propre à un objet, lue avec un point (mesure.hauteur).
Méthode
fonction définie dans une classe, appelée sur un objet (parcelle.ajouter(m)).
Dataclass
classe de données dont __init__, l'affichage et la comparaison sont écrits par le décorateur @dataclass.
Test automatisé
fonction qui vérifie un comportement attendu et que pytest lance avec les autres.
Fixture
fonction de pytest qui prépare ce dont un test a besoin (un dossier temporaire, un client de test).
Annotation de type
indication du type attendu (x: int, -> float), vérifiée par un outil, pas à l'exécution.
mypy
vérificateur de types statique pour Python.
Paquet
dossier de modules contenant un fichier __init__.py.
pyproject.toml
fichier qui décrit un projet Python : nom, version, Python requis, dépendances.
Installation modifiable
installation (pip install -e .) qui utilise directement les fichiers du projet, sans les copier.
API web
service interrogé par un programme au moyen de requêtes HTTP, qui répond avec des données.
Requête HTTP
demande envoyée par un client à un serveur (GET pour obtenir des données).
Code de statut
nombre qui résume la réponse d'un serveur (200 : OK ; 404 : introuvable ; 429 : trop de requêtes).
Cache
copie locale de données déjà obtenues, pour ne pas les redemander.
Base de données
ensemble organisé de données, rangées en tables et interrogées avec un langage de requêtes.
Table
ensemble de lignes ayant les mêmes colonnes, dans une base de données.
Requête SQL
instruction qui crée, modifie ou interroge les données d'une base.
Injection SQL
attaque où une saisie modifie une requête SQL construite avec du texte ; évitée par les paramètres ?.
Route
association entre une adresse d'une application web et la fonction qui y répond.
Gabarit
page HTML à trous, remplie avec des valeurs par le moteur de gabarits (Jinja).
Secret
mot de passe, clé ou jeton qui ne doit jamais figurer dans le code ni dans un dépôt.
Vulnérabilité
faiblesse connue d'un logiciel, qu'un attaquant peut exploiter ; elle se corrige en général par une mise à jour.
README
page d'accueil d'un dépôt, qui présente le projet, son installation et son utilisation.
Licence
texte qui dit ce que les autres ont le droit de faire d'un code ou de données.
Entretien technique
entretien où l'on présente son travail, explique ses choix et résout un petit problème.
Tutorat
accompagnement d'un étudiant par un autre, plus avancé, qui l'aide à apprendre sans faire à sa place.