Leçon 08-1 — Structure propre et nommage

1. Objectif

À la fin de cette leçon tu sauras : organiser un petit projet Python selon une arborescence standard, nommer variables, fonctions et modules selon les conventions PEP 8 essentielles, écrire des fonctions courtes à responsabilité unique, et séparer le calcul de l'affichage — la transformation qui rend un programme testable (c'est toute la préparation de la leçon 08-2).

2. Pourquoi c'est important

Au niveau 07 tu as écrit des programmes qui marchent. Ce niveau t'apprend à écrire des programmes qu'on peut maintenir — c'est-à-dire relire dans un mois, modifier sans tout casser, et tester automatiquement. Or 90 % de la vie d'un vrai programme, c'est de la maintenance, pas de l'écriture initiale. Les trois outils de cette leçon (structure, noms, séparation calcul/affichage) sont exactement ce qui distingue un script de débutant d'un projet qu'un autre développeur — ou toi dans six mois — peut reprendre. Et sans la séparation calcul/affichage, les tests de la leçon suivante sont tout simplement impossibles à écrire.

3. Explication simple

Trois idées, une par outil.

a) La structure : une place pour chaque chose. Un petit projet Python standard ressemble à ceci :

task-manager/
  README.md          ce que fait le projet, comment le lancer (⏩ leçon 08-4)
  main.py            le point d'entrée : c'est LUI qu'on exécute
  tasks.py           la logique : les fonctions qui calculent
  storage.py         la persistance : lire/écrire le fichier
  tests/             les tests automatisés (⏩ leçon 08-2)
    test_tasks.py

Tu connais déjà tout : les modules et les imports viennent du niveau 05. La nouveauté est la convention : logique, affichage, persistance et tests ne vivent pas au même endroit. Quand on cherche quelque chose, on sait où regarder.

b) Le nommage : le code se lit plus qu'il ne s'écrit. PEP 8 est le guide de style officiel de Python. L'essentiel tient en quatre règles :

QuoiConventionExemple
variables et fonctionssnake_case (minuscules + _)total_price, load_tasks
constantesMAJUSCULES_AVEC_UNDERSCORESMAX_RETRIES = 3
classesPascalCaseBankAccount (niveau 06)
modules (fichiers)snake_case courtstorage.py, pas MonSuperFichier.py

Et une règle d'or au-dessus des conventions : un nom dit ce que c'est, pas comment c'est calculé, et sans abréviation cryptique. remaining_budget bat rb, x2 et le_budget_qui_reste_apres_les_courses.

c) Une fonction = un travail. Une fonction qui calcule le total, demande confirmation, affiche le résultat ET sauvegarde le fichier fait quatre travails. On ne peut ni la réutiliser, ni la tester, ni la comprendre d'un coup d'œil. La version propre : quatre fonctions courtes, chacune avec un nom qui dit son unique travail.

4. Explication approfondie

La séparation calcul/affichage est LE point difficile de cette leçon — pas techniquement (tu sais déjà tout faire), mais parce qu'elle demande de casser une habitude prise depuis le niveau 01 : mettre des print() partout. Compte une bonne session rien que pour la digérer, et le mini-projet pour l'ancrer.

Compare ces deux versions d'une même fonctionnalité :

# Version A — calcul et affichage soudés
def show_cart_total(prices):
    total = sum(prices)
    print(f"Total : {total:.2f} EUR")
# Version B — calcul et affichage séparés
def cart_total(prices):
    return sum(prices)            # calcul : renvoie une VALEUR

def show_cart_total(prices):
    print(f"Total : {cart_total(prices):.2f} EUR")   # affichage : consomme la valeur

Les deux affichent la même chose. Mais la version A est une impasse :

  1. Intestable. Un test automatique (leçon 08-2) appelle une fonction et compare son résultat à une valeur attendue : assert f(x) == attendu. La version A renvoie None — il n'y a rien à comparer. Le texte est parti dans le terminal, hors de portée du programme.
  2. Non réutilisable. Demain tu veux le total pour calculer des frais de port ? Impossible : la version A ne le donne pas, elle l'affiche.
  3. Rigide. Changer l'affichage (devise, format, écrire dans un fichier) force à toucher la fonction de calcul — et risquer d'y introduire un bug.

La règle générale : le cœur du programme calcule et return ; une fine couche externe fait les input() et les print(). On appelle parfois le cœur des « fonctions pures » : même entrée → même sortie, aucun effet visible à l'extérieur (pas d'affichage, pas d'écriture de fichier). Plus ton cœur pur est gros et ta couche d'affichage fine, plus ton programme est sain.

Deux subtilités honnêtes :

Sur les fonctions courtes : la limite n'est pas un nombre de lignes magique. Le vrai critère est : peux-tu décrire la fonction en une phrase sans dire « et » ? « Elle calcule le total » → bien. « Elle calcule le total et l'affiche et sauvegarde » → à découper. En pratique, au-delà de ~20 lignes, méfiance.

5. Exemples commentés

Exemple 1 — renommer selon PEP 8

# Avant : on doit lire le corps pour deviner ce que font les noms
def calc(l, t):
    r = []
    for x in l:
        if x["s"] == t:
            r.append(x)
    return r

# Après : le code se lit comme une phrase
def filter_tasks_by_status(tasks, status):
    matching = []
    for task in tasks:
        if task["status"] == status:
            matching.append(task)
    return matching

Aucun comportement changé — seulement des noms. Note que la version « après » n'a plus besoin de commentaire : les noms FONT le travail du commentaire.

Exemple 2 — une constante nommée plutôt qu'un nombre magique

MAX_TITLE_LENGTH = 50    # constante : MAJUSCULES, définie en haut du module

def is_valid_title(title):
    return 0 < len(title) <= MAX_TITLE_LENGTH

if len(title) <= 50 aurait marché, mais 50 est un « nombre magique » : dans six mois, personne ne sait s'il peut le changer ni pourquoi il vaut 50. La constante nommée documente ET centralise (une seule ligne à changer).

Exemple 3 — découper une fonction fourre-tout

# Avant : une fonction, quatre travails (valider, calculer, formater, afficher)
def handle_purchase(prices, budget):
    if not prices:
        print("Panier vide !")
        return
    total = 0
    for price in prices:
        total = total + price
    if total > budget:
        print(f"Depassement : {total - budget:.2f} EUR")
    else:
        print(f"OK, reste {budget - total:.2f} EUR")
# Après : trois fonctions de calcul pures + une fine couche d'affichage
def cart_total(prices):
    return sum(prices)

def budget_overrun(total, budget):
    return total - budget          # positif = dépassement, négatif = marge

def purchase_report(prices, budget):
    if not prices:
        return "Panier vide !"
    overrun = budget_overrun(cart_total(prices), budget)
    if overrun > 0:
        return f"Depassement : {overrun:.2f} EUR"
    return f"OK, reste {-overrun:.2f} EUR"

def main():
    print(purchase_report([10.0, 25.5], 30.0))

main()

Sortie :

Depassement : 5.50 EUR

purchase_report renvoie le texte au lieu de l'afficher : demain, la leçon 08-2 pourra écrire assert purchase_report([], 30) == "Panier vide !". Avec la version « avant », c'était impossible.

Exemple 4 — l'arborescence en action (imports entre modules)

# tasks.py — la logique, AUCUN print
def add_task(tasks, title):
    tasks.append({"title": title, "done": False})
    return tasks

def format_task_line(task):
    box = "[x]" if task["done"] else "[ ]"
    return f"{box} {task['title']}"
# main.py — le point d'entrée : lui seul parle à l'utilisateur
from tasks import add_task, format_task_line

tasks = []
add_task(tasks, "buy milk")
add_task(tasks, "write tests")
tasks[1]["done"] = True

for task in tasks:
    print(format_task_line(task))

Sortie (en lançant python main.py depuis le dossier du projet) :

[ ] buy milk
[x] write tests

format_task_line construit le texte (calcul pur, testable), main.py fait le print. C'est exactement la répartition à viser.

6. Erreurs fréquentes

1) Le print incrusté dans la fonction de calcul

def cart_total(prices):
    total = sum(prices)
    print(f"Total : {total}")   # ← intrus
    return total

Ça « marche », mais la fonction affiche même quand on ne veut pas (dans un test, dans une boucle de 1000 appels…). Une fonction de calcul renvoie ; l'appelant décide s'il affiche. Correction : supprimer le print, l'appelant fait print(cart_total(prices)).

2) Le faux jumeau : return remplacé par print

def cart_total(prices):
    print(sum(prices))     # renvoie None !

result = cart_total([10, 20])   # affiche 30... mais result vaut None
print(result + 5)               # TypeError: unsupported operand type(s)
                                # for +: 'NoneType' and 'int'

Le piège du niveau 04 qui revient en force ici : afficher n'est pas renvoyer. Une fonction sans return renvoie None, et tout calcul dessus plante.

3) Nommer d'après le type ou l'implémentation, pas le sens

list1 = load_tasks()          # « list1 » : quel contenu ? mystère
dict_data = tasks[0]          # « dict_data » : on sait que c'est un dict, super…
tasks = load_tasks()          # ← le NOM porte le sens
first_task = tasks[0]

Le type se voit à l'usage ; le rôle ne se voit que dans le nom. Bannis aussi data, info, temp, result2 — des noms qui ne disent rien.

4) Une lettre d'écart entre deux conventions

def LoadTasks():      # PascalCase : réservé aux CLASSES (niveau 06)
    ...
def load_Tasks():     # mélange : ni l'un ni l'autre
    ...
def load_tasks():     # snake_case : correct pour une fonction
    ...

Python accepte les trois (aucune erreur !) — c'est un contrat entre humains. Le lecteur qui voit LoadTasks() s'attend à une classe et sera dérouté.

5) La fonction-tunnel de 60 lignes

Symptômes : tu scrolles pour la lire, elle contient trois niveaux de if imbriqués et des commentaires # étape 2 : .... Chaque commentaire « étape » est en fait le NOM d'une fonction qui demande à naître. Correction : extraire chaque étape en fonction nommée (technique détaillée leçon 08-4).

6) Tout garder dans un seul fichier « parce que ça marche »

Un main.py de 250 lignes marche, oui. Mais chercher la fonction de calcul de score dans une soupe de print et d'input est pénible, et les tests de la leçon 08-2 devront importer ce fichier — ce qui exécutera tout le code du niveau module (menus, input() bloquants…). Séparer logique et point d'entrée n'est pas de l'esthétique : c'est ce qui rend l'import possible.

7. Lire les messages d'erreur

Restructurer un projet, c'est déplacer des fichiers et renommer des fonctions — deux gestes qui produisent deux erreurs typiques.

a) ModuleNotFoundError : lancé depuis le mauvais dossier. Tu as bien créé tasks.py à la racine et tests/test_tasks.py, mais tu lances depuis l'intérieur de tests/ :

Traceback:
test_tasks.py:1: in <module>
    from tasks import add_task
E   ModuleNotFoundError: No module named 'tasks'

Lecture (de bas en haut, comme toujours) : la dernière ligne dit que Python n'a pas trouvé de module tasks ; la ligne au-dessus montre l'import fautif. Python cherche les modules à partir du dossier d'où tu lances (niveau 05). Depuis tests/, il n'y a pas de tasks.py visible. Correction : lancer depuis la racine du projet — c'est la règle pour toute la suite du niveau.

b) NameError : renommage incomplet. Tu renommes calc en compute_total, mais tu oublies un site d'appel :

Traceback (most recent call last):
  File "C:\Users\jtron\Claude\learnpython\main.py", line 4, in <module>
    print(compute_total_price([1, 2]))
          ^^^^^^^^^^^^^^^^^^^
NameError: name 'compute_total_price' is not defined

Dernière ligne : le nom appelé n'existe pas (ici on a tapé un nom hybride qui n'est ni l'ancien ni le nouveau). Ligne au-dessus : fichier et ligne exacts. Méthode anti-erreur : après un renommage, recherche globale de l'ancien nom dans tout le projet (Ctrl+Shift+F dans VS Code) — zéro occurrence restante avant de relancer. ⏩ À la leçon 08-2, ce sont les tests qui attraperont ce genre d'oubli à ta place : c'est précisément leur métier.

8. Exercices — faciles

Règles : fichiers dans lessons/level-08-tests-bonnes-pratiques/exercices/mes-reponses/, nommés ex-08-1-a.py, etc. Prédis le comportement AVANT d'exécuter.

a) Renomme tout ce qui doit l'être dans ce code (noms ET conventions), sans changer le comportement. Vérifie qu'il tourne toujours à l'identique :

def Calc(L):
    T = 0
    for X in L:
        T = T + X["p"] * X["q"]
    return T

d = [{"p": 2.5, "q": 4}, {"p": 1.0, "q": 6}]
print(Calc(d))

(Contexte : ce sont des lignes de commande — prix unitaire × quantité.)

b) Sur papier : range ces huit fichiers dans l'arborescence type de la section 3 (racine, ou tests/) et justifie chaque choix en une phrase — main.py, test_storage.py, README.md, contacts.py, storage.py, test_contacts.py, notes-perso.txt, search.py.

c) Dans le code suivant, souligne (en commentaire # CALCUL / # AFFICHAGE) chaque ligne selon sa nature, puis réécris-le en deux fonctions : une pure qui return, une d'affichage qui print :

def report(scores):
    best = max(scores)
    mean = sum(scores) / len(scores)
    print(f"Meilleur : {best}")
    print(f"Moyenne : {mean:.1f}")

9. Exercices — moyens

a) Cette fonction fait quatre travails. Découpe-la en fonctions courtes à responsabilité unique (au moins trois fonctions pures + une d'affichage), même comportement. Piège : l'une des étapes renvoie None si tu recopies sans réfléchir — pourquoi ? (Relis l'erreur fréquente n°2.)

def process_grades(grades_text):
    # grades_text ressemble a "12,15.5,9,18"
    parts = grades_text.split(",")
    grades = []
    for part in parts:
        grades.append(float(part))
    total = 0
    for grade in grades:
        total += grade
    mean = total / len(grades)
    if mean >= 10:
        print(f"Admis avec {mean:.2f}")
    else:
        print(f"Ajourne avec {mean:.2f}")

b) Prends l'exemple 4 (fichiers tasks.py + main.py), recrée-le dans un dossier ex-08-1-e/ de mes-reponses/, puis ajoute une fonction PURE count_done(tasks) dans tasks.py et affiche son résultat depuis main.py sous la forme Fait : 1/2. Contrainte : zéro print dans tasks.py. Vérifie ensuite l'erreur du 7-a : lance python main.py depuis le dossier parent (python ex-08-1-e/main.py marche-t-il ? pourquoi ?).

10. Exercices — difficiles

a) Restructure ce script monolithique en trois fichiers — converter.py (logique pure), history.py (persistance), main.py (interface) — dans un dossier ex-08-1-f/. Comportement strictement identique. Aucune fonction de converter.py ni history.py ne doit contenir print ou input.

# currency.py — tout-en-un a restructurer
RATE_EUR_USD = 1.08

print("=== Convertisseur EUR -> USD ===")
amount_text = input("Montant en EUR : ")
amount = float(amount_text)
converted = amount * RATE_EUR_USD
print(f"{amount:.2f} EUR = {converted:.2f} USD")
line = f"{amount:.2f};{converted:.2f}\n"
with open("history.csv", "a", encoding="utf-8") as f:
    f.write(line)
print("Conversion enregistree.")

11. Mini-projet lié — « opération chirurgie » sur le gestionnaire de tâches

Reprends TON gestionnaire de tâches du projet 07-2 (si tu ne l'as pas, utilise le script currency.py de l'exercice difficile comme patient de secours — moins riche mais même méthode) et opère-le, en 30–45 min :

  1. Diagnostic (10 min, sans rien changer) : liste chaque fonction dans un commentaire en tête de fichier avec un verdict — PURE (calcule et renvoie), AFFICHAGE, ou MIXTE (les patients à opérer).
  2. Chirurgie : pour chaque MIXTE, extrais la partie calcul dans une fonction pure ; la partie affichage consomme son résultat.
  3. Structure : répartis en modules (tasks.py / storage.py / main.py) si ce n'est pas déjà fait, et passe tous les noms en PEP 8.
  4. Vérification : le programme se comporte EXACTEMENT comme avant (mêmes menus, mêmes messages). Le refactoring ne change pas le comportement.

Critère de réussite : tasks.py importé depuis un autre fichier ne provoque NI affichage NI attente de saisie. Teste : python -c "import tasks" doit rendre la main en silence. Ce fichier servira de terrain de jeu à la leçon 08-2 — cette opération n'est pas un exercice de style, c'est le prérequis direct des tests.

12. Correction / méthode de correction

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

Méthode : (1) finis TOUS les exercices avant d'ouvrir les solutions ; (2) compare le raisonnement, surtout les découpages — deux découpages différents peuvent être aussi bons, l'important est que chaque fonction se décrive sans « et » ; (3) pour chaque écart, une ligne de journal : « je pensais X, en fait Y » ; (4) refais de tête l'exercice raté le lendemain.

13. À retenir

14. Questions de révision

  1. Pourquoi une fonction qui fait print(total) au lieu de return total est-elle impossible à tester automatiquement ?
  2. Quelle convention PEP 8 pour une constante ? Une fonction ? Une classe ?
  3. Qu'est-ce qu'un « nombre magique » et quel est le remède ?
  4. Comment reconnaît-on qu'une fonction fait plusieurs travails, sans compter ses lignes ?
  5. Une fonction qui construit et renvoie f"[x] {title}" est-elle « pure » ? Et une qui l'affiche ?
  6. Pourquoi import tasks ne doit-il déclencher ni affichage ni saisie ?
  7. Tu obtiens ModuleNotFoundError: No module named 'tasks' alors que tasks.py existe : quelle est la cause la plus probable ?
  8. Après avoir renommé une fonction, quel geste évite le NameError différé ?

15. Checklist de compréhension

Si une case reste vide : refais les exercices de la section correspondante demain, AVANT d'ouvrir la leçon suivante.

16. Commit conseillé

git add lessons/level-08-tests-bonnes-pratiques/exercices/mes-reponses/
git commit -m "exercises: complete lesson 08-1 structure and naming"

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