Leçon 08-4 — Refactoring, docstrings et README

1. Objectif

À la fin de cette leçon tu sauras : refactorer du code existant — renommer, extraire une fonction, supprimer un doublon — sans en changer le comportement, en utilisant ta suite de tests comme filet de sécurité ; écrire des docstrings utiles (quoi / paramètres / retour / erreurs) ; distinguer un commentaire qui explique un pourquoi d'un commentaire-bruit ; et écrire le README qui permet à un inconnu d'installer et d'utiliser ton projet.

2. Pourquoi c'est important

C'est la leçon qui boucle le niveau : les leçons 08-1 à 08-3 t'ont fait écrire des tests, celle-ci te fait toucher les dividendes. Refactorer sans tests, c'est marcher sur un fil sans filet — la plupart des débutants ne retouchent jamais leur code qui marche, par peur justifiée de le casser. Avec une suite verte, cette peur disparaît : tu modifies, tu relances, la suite te dit si le comportement a bougé. C'est ce cycle qui fait progresser un code réel (le premier jet n'est JAMAIS le bon découpage). Quant aux docstrings et au README : ton code sera lu par d'autres — des collègues, des recruteurs sur GitHub, et surtout toi-dans-six-mois, qui est un parfait inconnu. Un projet sans README est un projet mort à l'arrivée : personne ne devine comment le lancer.

3. Explication simple

Refactorer = changer la forme, pas le fond. Le programme fait exactement la même chose avant et après ; seuls les noms, le découpage, l'organisation changent. Le cycle, toujours le même :

  1. Vert — lance les tests AVANT de toucher quoi que ce soit (le filet doit être en place et intact).
  2. Un seul geste — renomme UNE fonction, ou extrais UNE fonction, ou supprime UN doublon. Pas trois choses à la fois.
  3. Vert à nouveau — relance. Vert : le geste est validé, commit. Rouge : c'est TON geste qui a cassé (dernier geste = seul suspect), répare ou annule.

La docstring = le mode d'emploi embarqué. Une chaîne entre triples guillemets, première ligne du corps de la fonction :

def cart_total(prices):
    """Return the sum of all prices in the cart."""
    return sum(prices)

Elle dit ce que la fonction FAIT (pour l'appelant), pas comment elle le fait. C'est elle que help(cart_total) affiche, et que VS Code montre en survol.

Le commentaire = le pourquoi. Le code dit déjà ce qu'il fait (surtout avec les noms de la leçon 08-1). Un bon commentaire explique ce que le code ne peut pas dire : un choix, une raison, un piège.

i += 1                    # incremente i          ← bruit : paraphrase
total = round(total, 2)   # les prix IBKR arrivent avec 6 decimales,
                          # on n'affiche jamais plus que le centime    ← utile

Le README = la porte d'entrée. Un fichier README.md à la racine, qui répond dans l'ordre aux questions d'un nouvel arrivant : c'est quoi ? comment je l'installe ? comment je m'en sers ? à quoi ressemble le résultat ?

4. Explication approfondie

a) Pourquoi « un seul geste à la fois » est LA règle du refactoring. Si tu renommes trois fonctions, extrais un doublon ET « corriges un petit truc au passage », puis que la suite vire au rouge — le coupable est introuvable : quatre suspects, zéro certitude. Pire : le « petit truc corrigé au passage » est un changement de comportement camouflé en refactoring, et si un test le signale, tu seras tenté de « corriger le test » — détruisant ton filet. La discipline stricte : refactoring et modification de comportement sont deux activités séparées, deux commits séparés. Pendant un refactoring, un test rouge signifie TOUJOURS « j'annule ou je répare mon geste », jamais « je modifie le test ». (Un test ne se modifie pendant un refactoring que si le geste change une interface — un renommage public, cf. point b.)

b) Les trois gestes fondamentaux.

c) La docstring honnête : quoi, paramètres, retour, erreurs. Le format complet (pour les fonctions non triviales — une ligne suffit pour cart_total) :

def apply_discount(price, percent):
    """Return the price after applying a percentage discount.

    Args:
        price: original price (positive number).
        percent: discount between 0 and 100.

    Returns:
        The discounted price, as a float.

    Raises:
        ValueError: if percent is outside [0, 100].
    """

Remarque la section Raises : c'est le contrat de la leçon 08-3, écrit noir sur blanc pour l'appelant. Règle d'or : une docstring qui répète le nom de la fonction sans rien ajouter ("""Applies discount.""") est du bruit ; une docstring fausse (pas mise à jour après un changement) est pire que pas de docstring — elle ment avec autorité. On met à jour la docstring dans le même geste que le code, ou on la supprime.

d) Le README minimal qui marche. Cinq blocs, dans cet ordre :

  1. Titre + une phrase : ce que fait le projet, pour qui.
  2. Installation : les commandes exactes, copiables (git clone, python -m pip install pytest si les tests en ont besoin…).
  3. Usage : LA commande pour lancer (python main.py), et les options.
  4. Exemple : une session réelle copiée du terminal — c'est le bloc que tout le monde lit en premier, il vaut dix paragraphes.
  5. Tests (bonus qui fait sérieux) : python -m pytest.

Le test d'acceptation d'un README : quelqu'un qui n'a jamais vu le projet peut-il le faire tourner en suivant uniquement le README, sans te poser une question ? Chaque question qu'on te pose est un trou dans le README.

Difficulté honnête : le refactoring demande du courage au début — on touche à du code qui marche, et le premier test rouge en plein geste fait peur. C'est le moment clé de tout le niveau : ce rouge-là n'est pas un échec, c'est le filet qui vient de te RATTRAPER. Sans lui, ce bug partait en production. Attends-toi aussi à un effet pervers : une fois à l'aise, tu voudras TOUT refactorer, tout le temps. Résiste : on refactore ce qu'on s'apprête à modifier ou ce qui gêne la lecture, pas le code stable qui ne dérange personne.

5. Exemples commentés

Exemple 1 — extraire une fonction (le doublon paie la facture)

Avant : deux blocs quasi identiques dans report.py :

def weekly_report(tasks):
    done = 0
    for task in tasks:
        if task["done"]:
            done += 1
    return f"Semaine : {done}/{len(tasks)} taches terminees"

def monthly_report(tasks):
    done = 0
    for task in tasks:
        if task["done"]:
            done += 1
    return f"Mois : {done}/{len(tasks)} taches terminees"

Cycle : tests verts d'abord, puis UN geste — extraire le comptage :

def count_done(tasks):
    """Return the number of completed tasks."""
    done = 0
    for task in tasks:
        if task["done"]:
            done += 1
    return done

def weekly_report(tasks):
    return f"Semaine : {count_done(tasks)}/{len(tasks)} taches terminees"

def monthly_report(tasks):
    return f"Mois : {count_done(tasks)}/{len(tasks)} taches terminees"

Relance : vert (les tests de weekly_report et monthly_report n'ont pas bougé d'une ligne — le comportement non plus). Commit. Bonus immédiat : count_done est désormais testable et réutilisable seule.

Exemple 2 — le filet attrape une régression en plein refactoring

En « simplifiant » grade_to_mention (leçon 08-3), on réordonne les if — et on met le seuil le plus BAS en premier :

def grade_to_mention(grade):
    if grade >= 10:            # bug : 16 est aussi >= 10...
        return "passable"
    if grade >= 12:
        return "assez bien"
    ...
test_grades.py F..                                                       [100%]

================================== FAILURES ===================================
_________________________ test_grade_16_is_tres_bien __________________________

    def test_grade_16_is_tres_bien():
>       assert grade_to_mention(16) == "tres bien"
E       AssertionError: assert 'passable' == 'tres bien'
E         
E         - tres bien
E         + passable

test_grades.py:5: AssertionError

Dix secondes après le geste, le filet a rattrapé : 16 tombe dans le premier if rencontré. C'est LE moment que tout le niveau préparait — sans test, ce bug dormait jusqu'à la prochaine vraie note de 16. Réaction correcte : annuler ou réparer le geste (les seuils descendants : 16, 14, 12, 10). Réaction interdite : modifier le test.

Exemple 3 — la docstring et son retour sur investissement

>>> help(apply_discount)
Help on function apply_discount in module cart:

apply_discount(price, percent)
    Return the price after applying a percentage discount.

    Args:
        price: original price (positive number).
        percent: discount between 0 and 100.

    Returns:
        The discounted price, as a float.

    Raises:
        ValueError: if percent is outside [0, 100].

C'est la docstring du point 4-c, servie par help() — et par VS Code au survol de chaque appel. Écrite une fois, lue à chaque utilisation : c'est le meilleur ratio effort/service de toute la leçon.

Exemple 4 — triage de commentaires

# --- bruit : paraphrase ce que le code dit deja -------------------------
tasks = []                          # cree une liste vide
done += 1                           # ajoute 1 a done
return None                         # renvoie None

# --- utile : explique un pourquoi invisible dans le code ----------------
time.sleep(1)      # the API rejects more than 1 request per second
prices = prices[:] # copy: caller's list must not be modified (level 03!)
if len(text) > 47: # 47 = 50 (max width) minus 3 for the "..." suffix

Test rapide pour trancher : masque le commentaire — si le code seul dit la même chose, supprime ; si une information disparaît (une contrainte externe, une raison, un piège de mutabilité), garde. Note aussi : les trois commentaires « utiles » vieilliront — ils décrivent des contraintes qui peuvent changer. C'est accepté : un commentaire pourquoi se relit à chaque modification du bloc qu'il annote.

Exemple 5 — un README complet et court

# Task Manager

Gestionnaire de taches en ligne de commande : ajout, completion, suppression,
avec sauvegarde automatique dans un fichier JSON.

## Installation

    git clone https://github.com/jtron/task-manager.git
    cd task-manager

Aucune dependance externe (Python 3.10+). Pour lancer les tests :

    python -m pip install pytest

## Usage

    python main.py

## Exemple

    === Task Manager ===
    1. Ajouter  2. Terminer  3. Lister  4. Quitter
    > 3
    [x] write tests
    [ ] refactor storage module

## Tests

    python -m pytest

Vingt-cinq lignes, zéro blabla, et l'inconnu du test d'acceptation s'en sort seul. Remarque l'exemple : une session RÉELLE copiée du terminal, pas une promesse.

6. Erreurs fréquentes

1) Refactorer ET changer le comportement dans le même geste

Le piège capital (section 4-a). Symptôme : « pendant que j'extrayais la fonction, j'ai aussi corrigé l'arrondi ». Le test rouge est alors ambigu : régression ou correction voulue ? Un geste à la fois, un commit par geste — refactor: extract count_done PUIS fix: round totals to 2 decimals.

2) Partir refactorer sans vérifier que la suite est verte

Tu refactores, la suite est rouge... était-elle déjà rouge avant ? Impossible à dire, ton filet était troué d'entrée. TOUJOURS python -m pytest avant le premier geste : on ne refactore que sur du vert.

3) « Corriger le test » pour faire passer le refactoring

def test_grade_16_is_tres_bien():
    assert grade_to_mention(16) == "passable"   # « corrige » pour etre vert...

Le test de l'exemple 2 gênait, on l'a aligné sur le bug. La suite est verte et le programme est faux : tu as scié ton filet. Pendant un refactoring, le test est le juge, pas l'accusé.

4) La docstring qui ment

def cart_total(prices):
    """Return the sum of all prices, rounded to 2 decimals."""
    return sum(prices)          # l'arrondi a ete retire... la docstring non

Aucun outil ne signale une docstring périmée — elle trompera chaque lecteur avec assurance. Règle : le geste qui change le comportement d'une fonction inclut la mise à jour de sa docstring (et du README si l'usage change).

5) Le commentaire-bruit qui enterre le commentaire utile

total = 0                # initialise le total
for p in prices:         # boucle sur les prix
    total += p           # ajoute le prix
total = round(total, 2)  # round because floats accumulate errors (lesson 01-2)

Trois paraphrases pour une vraie information : le lecteur a désappris à lire les commentaires avant d'arriver à celui qui compte. Supprime le bruit, le signal ressort.

6) Le README aspirationnel

Un README qui documente l'export CSV « prévu pour bientôt », une commande python app.py alors que le fichier s'appelle main.py, une installation jamais re-testée depuis un clone frais. Le README décrit ce qui EST, au présent, vérifié. Méthode : rejoue toi-même chaque commande du README dans un dossier vide (clone compris) avant de le déclarer fini.

7. Lire les messages d'erreur

a) Le rouge de régression : ton meilleur ennemi. Pendant un refactoring, l'échec type est celui de l'exemple 2 :

E       AssertionError: assert 'passable' == 'tres bien'
E         
E         - tres bien
E         + passable

Lecture spéciale refactoring : peu importe le détail du diff — l'information capitale est que le comportement a bougé alors qu'il ne devait pas. Le suspect est unique : ton dernier geste. Deux issues, dans cet ordre de préférence : (1) si le geste est petit, trouve l'erreur et répare (ici, réordonner les seuils) ; (2) si tu ne vois pas en deux minutes, ANNULE (Ctrl+Z ou git checkout -- fichier — le refactoring n'était pas commité, n'est-ce pas ?) et rejoue le geste en plus petit. Ne jamais partir en débogage-fleuve sur un refactoring non commité : annuler coûte 10 secondes.

b) ImportError après un renommage : l'appelant oublié.

=================================== ERRORS ====================================
________________________ ERROR collecting test_cart.py ________________________
Traceback:
test_cart.py:1: in <module>
    from cart import calc
E   ImportError: cannot import name 'calc' from 'cart' (C:\...\cart.py)

Tu as renommé calc en compute_cart_total dans cart.py, mais test_cart.py (ou main.py) importe encore l'ancien nom. ERROR collecting (leçon 08-2) : rien n'a tourné. La ligne E est limpide : le nom calc n'existe plus dans cart. Correction : recherche globale de l'ancien nom, mise à jour de TOUS les appelants, relance. C'est le coût normal d'un renommage public — bruyant, immédiat, donc sans danger. (Le renommage dangereux, c'est celui qui ne casse rien de visible : une variable locale mal renommée à la main dans une fonction de 60 lignes… d'où l'intérêt des fonctions courtes.)

8. Exercices — faciles

Règles : sous-dossiers ex-08-4-x/ dans lessons/level-08-tests-bonnes-pratiques/exercices/mes-reponses/. Pour chaque exercice de refactoring : suite verte AVANT, un geste, suite verte APRÈS — c'est la consigne implicite de toute la série.

a) Écris les docstrings des trois fonctions de median/average/ grade_to_mention telles que tu les as laissées à la leçon 08-3 (format complet : résumé, Args, Returns, Raises quand il y a un contrat d'erreur). Vérifie chacune avec help() dans le REPL.

b) Triage : recopie ce bloc et, pour chaque commentaire, note BRUIT (et supprime) ou UTILE (et garde), en justifiant d'un mot :

MAX_TITLE_LENGTH = 50   # longueur maximale d'un titre

def add_task(tasks, title):
    title = title.strip()          # enleve les espaces
    if not title:                  # si le titre est vide
        raise ValueError("task title cannot be empty")
    if len(title) > MAX_TITLE_LENGTH:
        title = title[:MAX_TITLE_LENGTH - 3] + "..."   # -3 : place pour "..."
    tasks.append({"title": title, "done": False})      # ajoute la tache
    return tasks                   # renvoie la liste

c) Reprends grades.py et sa suite de la leçon 08-3 (suite verte). Renomme grade_to_mention en mention_from_grade PROPREMENT : recherche globale, mise à jour des appelants et des tests, suite verte. Puis provoque volontairement l'ImportError de la section 7-b (annule la mise à jour du test), lis l'erreur, re-corrige.

9. Exercices — moyens

a) Refactoring sous filet, gestes séparés. Ce module est moche mais correct. Écris d'abord sa suite de tests (nominal + limites, leçon 08-3), verte. Puis trois gestes SÉPARÉS, suite relancée entre chaque : (1) renomme tout ce qui doit l'être, (2) extrais le doublon, (3) remplace les nombres magiques par des constantes.

def f1(t):
    r = []
    for x in t:
        if len(x) > 30:
            r.append(x[:27] + "...")
        else:
            r.append(x)
    return r

def f2(t):
    r = []
    for x in t:
        if len(x) > 30:
            r.append(x[:27] + "...")
        else:
            r.append(x)
    return sorted(r)

(Contexte : des titres de tâches à tronquer pour l'affichage. Piège : après l'extraction, f2 doit-elle appeler f1 ou la fonction extraite ? Les deux donnent du vert — argumente ton choix dans un commentaire pourquoi.)

b) Écris le README complet de ton gestionnaire de tâches (les cinq blocs de la section 4-d), puis fais-lui passer le test d'acceptation POUR DE VRAI : clone ton propre dépôt dans un dossier temporaire, suis ton README à la lettre, commande par commande, dans un terminal frais. Chaque accroc = une correction du README, pas une explication orale.

10. Exercices — difficiles

a) Le grand ménage. Prends la PLUS LAIDE fonction de tes projets du niveau 07 (critères : > 25 lignes, ou mélange calcul/affichage résiduel, ou commentaires # etape N). Mission, dans l'ordre strict : (1) écris sa suite de tests SI elle n'existe pas — si la fonction est intestable, c'est le refactoring 08-1 d'abord (séparer calcul/affichage), avec vérification manuelle avant/après puisque sans filet ; (2) suite verte ; (3) démonte-la en gestes atomiques — une extraction par commentaire # etape, renommages, constantes — suite relancée entre CHAQUE geste ; (4) docstrings sur chaque fonction née du démontage ; (5) un commit par geste, messages refactor: ....

11. Mini-projet lié — le projet présentable

Le mini-projet final du niveau : transforme ton MEILLEUR projet du niveau 07 en projet montrable à un inconnu (recruteur, collègue), en 45 min :

  1. Filet : sa suite de tests (leçons 08-2/08-3) est verte. Sinon, commence par là.
  2. Refactoring final : passe en revue chaque fonction avec la grille — nom PEP 8 ? une seule responsabilité ? testable ? Applique les gestes nécessaires, un par un, sous filet.
  3. Docstrings : chaque fonction publique a la sienne (une ligne pour les triviales, format complet pour celles qui ont des Args/Raises non évidents). Une docstring de MODULE en tête de chaque fichier (une phrase : ce que contient le module).
  4. Commentaires : triage complet — chaque commentaire survivant explique un pourquoi que tu peux défendre.
  5. README : les cinq blocs, testés par rejeu dans un dossier vide.
  6. Commit final et relecture du diff complet : c'est la photo avant/après de tout le niveau 08.

Critère de réussite : montre le dépôt à quelqu'un (ou relis-le à froid demain) : il doit pouvoir dire ce que fait le projet, le lancer et lancer les tests, sans une seule question. C'est le standard à maintenir pour tout projet à partir du niveau 09.

12. Correction / méthode de correction

Les solutions détaillées sont dans solutions/serie-08-4-solutions.md.

Méthode : (1) finis TOUS les exercices avant d'ouvrir les solutions ; (2) sur les refactorings, compare la séquence de gestes autant que le résultat — arriver au même code par des gestes non protégés (sans relancer la suite) n'est pas une réussite ; (3) sur les docstrings et le README, compare ce que la solution a JUGÉ digne d'être dit — c'est le jugement qui s'apprend, pas la syntaxe ; (4) refais de tête l'exercice raté le lendemain.

13. À retenir

14. Questions de révision

  1. Définis le refactoring en une phrase, et cite le cycle en trois temps.
  2. Pourquoi « un seul geste à la fois » ? Donne les deux problèmes causés par les gestes multiples.
  3. Pendant un refactoring, un test vire au rouge. Quelles sont les deux réactions correctes, et LA réaction interdite ?
  4. Que contient une docstring complète, et pour qui est-elle écrite ?
  5. Pourquoi une docstring périmée est-elle pire qu'absente ?
  6. Donne un exemple de commentaire-bruit et un exemple de commentaire pourquoi, de ta propre main.
  7. Quels sont les cinq blocs d'un README, et quel est son test d'acceptation ?
  8. Après un renommage, quelle erreur signale un appelant oublié, et quel geste l'évite ?

15. Checklist de compréhension

Si une case reste vide : refais les exercices de la section correspondante demain, AVANT de clore le niveau (et relis les critères de sortie du README.md du niveau).

16. Commit conseillé

git add lessons/level-08-tests-bonnes-pratiques/exercices/mes-reponses/
git commit -m "exercises: complete lesson 08-4 refactoring docstrings readme"

Puis, si c'est ta fin de session : entrée de journal + git push.