Solutions — série 08-1 (structure propre et nommage)

Rappel de la règle d'or : on n'ouvre ce fichier qu'après avoir terminé (ou sérieusement séché sur) la série. Compare le raisonnement, pas seulement le code : sur les découpages en particulier, deux solutions différentes peuvent être aussi bonnes — le critère est que chaque fonction se décrive sans « et ».


Exercice facile a — renommer selon PEP 8

Raisonnement — Avant de renommer, comprendre : la fonction parcourt des lignes de commande (prix unitaire × quantité) et cumule. Donc les bons noms parlent de commande, de lignes, de total. Ensuite seulement, appliquer les conventions : snake_case pour la fonction et les variables, pas de majuscules hors classes. Le comportement ne doit pas bouger d'un iota — on vérifie en relançant : même sortie 16.0.

Solution

# ex-08-1-a.py
def order_total(order_lines):
    total = 0
    for line in order_lines:
        total = total + line["unit_price"] * line["quantity"]
    return total


order_lines = [
    {"unit_price": 2.5, "quantity": 4},
    {"unit_price": 1.0, "quantity": 6},
]
print(order_total(order_lines))

Sortie (identique à l'originale) :

16.0

Les clés du dictionnaire aussi ont été renommées ("p""unit_price", "q""quantity") : les clés sont des noms comme les autres, elles se lisent partout où le dictionnaire circule.

Pourquoi ça marche — Renommer est une transformation purement textuelle : tant que CHAQUE occurrence de l'ancien nom est mise à jour (définition ET usages, y compris les clés), Python exécute exactement le même programme. Le gain est pour le lecteur : order_total(order_lines) se comprend sans ouvrir le corps de la fonction ; Calc(d) exigeait de tout lire.

Erreur classique sur cet exercice — Renommer la variable de boucle X en line dans le for mais oublier une occurrence dans le corps (X["unit_price"]) → NameError: name 'X' is not defined à l'exécution. D'où la méthode : renommage = recherche de TOUTES les occurrences, puis relance immédiate. Autre classique : garder Calc avec sa majuscule « parce que ça marche » — Python accepte, mais le lecteur y verra une classe (section 6, erreur n°4 de la leçon).

Variante plus difficile — Ajoute une ligne dont la quantité est 0 et une liste vide order_total([]). Les deux doivent donner des totaux corrects (0). Tu viens d'écrire tes deux premiers « cas limites » — c'est le cœur de la leçon 08-3.


Exercice facile b — ranger l'arborescence

Raisonnement — La question à poser pour chaque fichier : est-ce du code de LOGIQUE, du TEST, de la DOCUMENTATION, ou du bruit personnel ? La convention de la leçon : tests dans tests/, le reste des .py à la racine (projet plat — suffisant à cette échelle), README.md à la racine, et ce qui n'appartient pas au projet… n'appartient pas au projet.

Solution

carnet-contacts/
  README.md            documentation : racine, TOUJOURS — c'est la porte d'entrée
  main.py              point d'entrée : racine, c'est lui qu'on lance
  contacts.py          logique métier : racine (module importable)
  storage.py           persistance : racine (module importable)
  search.py            logique de recherche : racine (module importable)
  tests/
    test_contacts.py   les tests testent contacts.py → tests/
    test_storage.py    idem pour storage.py → tests/

notes-perso.txt : nulle part dans le projet. Ce sont des notes à toi — dans ton journal, ou hors du dépôt. (Si tu veux vraiment le garder dans le dossier, il se liste dans .gitignore — vu au niveau 00.)

Pourquoi ça marche — La convention tests/ séparé + modules à la racine est celle que pytest et tout l'écosystème attendent : python -m pytest lancé de la racine trouve tests/ tout seul, et les imports from contacts import ... fonctionnent. Un inconnu (ou toi dans six mois) sait où chercher quoi sans réfléchir.

Erreur classique sur cet exercice — Mettre main.py dans un sous-dossier src/ « pour faire pro » en laissant les tests à la racine, ou éparpiller les test_*.py à côté de chaque module. Les deux se défendent dans certains projets réels, mais mélanger les conventions dans UN projet est pire que choisir la plus simple : à notre échelle, plat + tests/ est le standard.

Variante plus difficile — Le projet grossit : où iraient un fichier de données contacts.json, un script ponctuel import_from_csv.py, et une constante partagée MAX_CONTACTS ? (Réponses défendables : data/ ou racine pour le JSON ; racine ou scripts/ pour l'outil ponctuel ; la constante dans le module qui « possède » la notion — contacts.py — et importée ailleurs.)


Exercice facile c — séparer calcul et affichage

Raisonnement — Étiqueter d'abord : best = max(scores) et mean = sum(scores) / len(scores) sont du CALCUL ; les deux print sont de l'AFFICHAGE. Le découpage suit l'étiquetage : les calculs partent dans des fonctions qui return, l'affichage les consomme. Deux petites fonctions pures valent mieux qu'une seule qui renverrait « les deux à la fois » — chacune se décrit sans « et ».

Solution

# ex-08-1-c.py
def best_score(scores):        # CALCUL
    return max(scores)


def mean_score(scores):        # CALCUL
    return sum(scores) / len(scores)


def show_report(scores):       # AFFICHAGE : consomme les calculs
    print(f"Meilleur : {best_score(scores)}")
    print(f"Moyenne : {mean_score(scores):.1f}")


show_report([12, 15, 9])

Sortie (identique à l'originale) :

Meilleur : 15
Moyenne : 12.0

Pourquoi ça marchebest_score et mean_score sont désormais des fonctions pures : même entrée → même sortie, aucun affichage. À la leçon 08-2, assert mean_score([12, 15, 9]) == 12.0 devient possible. show_report reste intestable automatiquement — et c'est accepté : c'est la fine couche d'affichage, elle ne contient plus aucune logique qui vaille un test.

Erreur classique sur cet exercice — Le compromis bancal : une fonction qui calcule ET renvoie ET affiche (print puis return). Elle semble « faire les deux » mais cumule les défauts : elle pollue le terminal à chaque usage dans un calcul, et son test affichera du bruit. Le print ne vit QUE dans la couche d'affichage.

Variante plus difficilemean_score([]) plante (ZeroDivisionError). Quel devrait être le comportement ? Ne corrige pas encore — note ta réponse dans le journal : c'est exactement le sujet de la leçon 08-3 (décider du contrat des cas limites).


Exercice moyen a — découper process_grades

Raisonnement — Lister les travails de la fonction : (1) découper le texte et convertir en nombres, (2) calculer la moyenne, (3) décider admis/ajourné et formater le verdict, (4) afficher. Un travail = une fonction ; seuls le (4) a le droit de print. Le piège annoncé (le None) se cache dans le (3) : si on en fait une fonction qui print le verdict au lieu de le return, tout appelant qui voudra la valeur recevra None — c'est l'erreur fréquente n°2 de la leçon.

Solution

# ex-08-1-d.py
def parse_grades(grades_text):
    """'12,15.5,9' -> [12.0, 15.5, 9.0]"""
    grades = []
    for part in grades_text.split(","):
        grades.append(float(part))
    return grades


def mean(values):
    return sum(values) / len(values)


def admission_verdict(mean_value):
    if mean_value >= 10:
        return f"Admis avec {mean_value:.2f}"      # RETURN, pas print !
    return f"Ajourne avec {mean_value:.2f}"


def process_grades(grades_text):                   # couche d'affichage
    print(admission_verdict(mean(parse_grades(grades_text))))


process_grades("12,15.5,9,18")

Sortie (identique à l'originale) :

Admis avec 13.62

Pourquoi ça marche — Chaque fonction se décrit sans « et » : parse convertit, mean moyenne, admission_verdict formate la décision, process_grades affiche. Les trois premières sont pures et testables (assert admission_verdict(13.625) == "Admis avec 13.62" — remarque au passage : 13.625 s'affiche 13.62, un arrondi de f-string, pas une erreur). Et admission_verdict est réutilisable : demain, l'écrire dans un fichier ou l'envoyer par mail ne demande AUCUNE modification.

Erreur classique sur cet exercice — Tomber dans le piège annoncé :

def admission_verdict(mean_value):
    if mean_value >= 10:
        print(f"Admis avec {mean_value:.2f}")      # renvoie None !

Le programme « marche » à l'œil (le texte s'affiche), mais verdict = admission_verdict(13.6) vaut None, et le jour où on veut composer (stocker le verdict, le tester), tout casse. Symptôme dans un test : assert None == "Admis avec 13.60".

Variante plus difficile — Que fait parse_grades("12,,15") ? Et parse_grades("") ? (Réponses : ValueError sur float("") dans les deux cas.) Décide quel comportement SERAIT correct et note-le — tu écriras le test à la leçon 08-3.


Exercice moyen b — tasks.py + main.py + count_done

Raisonnementcount_done est un calcul (elle compte et renvoie un nombre) : sa place est dans tasks.py, sans print. Le format Fait : 1/2 demande deux nombres : les tâches faites (count_done) et le total (len(tasks)) — le formatage se fait dans main.py (ou dans une fonction pure de formatage, au choix).

Solution

# ex-08-1-e/tasks.py
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']}"


def count_done(tasks):
    done = 0
    for task in tasks:
        if task["done"]:
            done += 1
    return done
# ex-08-1-e/main.py
from tasks import add_task, count_done, 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))
print(f"Fait : {count_done(tasks)}/{len(tasks)}")

Sortie (python main.py depuis ex-08-1-e/) :

[ ] buy milk
[x] write tests
Fait : 1/2

Pourquoi ça marchecount_done est pure : on pourra écrire assert count_done([]) == 0 dès la leçon 08-2. Et la question du lancement depuis le parent : python ex-08-1-e/main.py fonctionne aussi — quand on lance un SCRIPT, Python ajoute le dossier DU SCRIPT au chemin des imports, donc from tasks import ... trouve son module. Attention : ce confort est propre au lancement de script ; avec pytest (leçon 08-2), c'est le dossier de LANCEMENT qui compte — d'où la règle « toujours depuis la racine ».

Erreur classique sur cet exercice — Glisser le print(f"Fait : ...") dans count_done « puisqu'on y est ». C'est exactement le réflexe que la leçon combat : la fonction ferait deux travails, et compter des tâches afficherait du texte à chaque appel. Le print reste dans main.py.

Variante plus difficile — Ajoute count_remaining(tasks). Deux implémentations possibles : une boucle jumelle, ou len(tasks) - count_done(tasks). Laquelle préfères-tu, et pourquoi ? (La seconde : zéro duplication, et si la définition de « fait » change un jour, UN seul endroit à corriger — c'est l'argument anti-doublon de la leçon 08-4.)


Exercice difficile a — restructurer le convertisseur

Raisonnement — Inventaire des travails du script : convertir (calcul pur), formater la ligne d'historique (calcul pur — construire une chaîne EST un calcul), écrire dans le fichier (effet, mais isolable dans history.py), dialoguer (input/print — main.py). La constante RATE_EUR_USD appartient à la logique de conversion : converter.py. Ensuite on colle : main.py enchaîne saisie → conversion → affichage → historisation.

Solution

# ex-08-1-f/converter.py
RATE_EUR_USD = 1.08


def convert(amount, rate):
    return amount * rate
# ex-08-1-f/history.py
HISTORY_FILE = "history.csv"


def format_history_line(amount, converted):
    return f"{amount:.2f};{converted:.2f}\n"


def append_history(amount, converted):
    with open(HISTORY_FILE, "a", encoding="utf-8") as history_file:
        history_file.write(format_history_line(amount, converted))
# ex-08-1-f/main.py
from converter import RATE_EUR_USD, convert
from history import append_history

print("=== Convertisseur EUR -> USD ===")
amount = float(input("Montant en EUR : "))
converted = convert(amount, RATE_EUR_USD)
print(f"{amount:.2f} EUR = {converted:.2f} USD")
append_history(amount, converted)
print("Conversion enregistree.")

Comportement strictement identique au script d'origine (mêmes messages, même fichier history.csv, mêmes formats).

Pourquoi ça marcheconvert et format_history_line sont pures et testables (assert convert(100, 1.08) == 108.0, assert format_history_line(100, 108.0) == "100.00;108.00\n"). append_history n'est pas pure (elle écrit un fichier) mais elle est COURTE et isolée : le jour où on la teste, on pourra lui faire écrire dans un fichier jetable. Et main.py est cinq lignes de colle, comme promis par l'indice 3 : il ne contient plus AUCUNE logique qui vaille un test.

Erreur classique sur cet exercice — Mettre format_history_line dans main.py « parce que c'est du formatage d'affichage ». Non : cette chaîne va dans un FICHIER, son format est un contrat de données (le CSV sera relu) — ça appartient à history.py, près de l'écriture. L'autre classique : laisser input() dans converter.py pour « simplifier », ce qui rend import converter bloquant — le critère du mini-projet (python -c "import converter" silencieux) le détecte immédiatement.

Variante plus difficile — Ajoute la conversion inverse (USD → EUR) SANS dupliquer convert (indice : convert(amount, 1 / RATE_EUR_USD) — ou une constante dédiée), avec un choix de sens dans le menu. Puis observe : seule main.py et une constante ont changé. C'est la récompense de la structure.


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

Pas de solution unique ici : le patient est TON code. Les critères d'une opération réussie, dans l'ordre du protocole :

1. Le diagnostic est honnête. Exemple de ce à quoi il doit ressembler :

# Diagnostic 08-1 :
#   load_tasks(path)        -> PURE (lit un fichier, renvoie une liste)  [OK]
#   save_tasks(path, tasks) -> effet fichier, isole                      [OK]
#   add_task(...)           -> MIXTE : ajoute ET print une confirmation  [A OPERER]
#   show_menu()             -> AFFICHAGE                                 [OK]
#   complete_task(...)      -> MIXTE : modifie ET print                  [A OPERER]
#   main()                  -> AFFICHAGE/orchestration                   [OK]

(Remarque : une fonction qui lit ou écrit un fichier n'est pas « pure » au sens strict, mais elle est à sa place si elle ne fait QUE ça — le critère opérationnel du niveau est : pas de print/input hors couche d'affichage.)

2. Chaque MIXTE est séparé en un calcul qui return (la nouvelle valeur, la liste modifiée, un booléen de succès…) et un affichage dans main.py qui consomme ce résultat. Le message de confirmation est construit à partir de la valeur RENVOYÉE — si tu ne peux pas le construire, c'est que ta fonction pure ne renvoie pas assez d'information : enrichis son return.

3. Le critère final passe :

python -c "import tasks"     # rend la main en silence : aucune sortie,
                             # aucune attente de saisie

S'il affiche quelque chose : du code s'exécute au niveau du module (hors fonctions) — déplace-le dans main(). S'il attend : un input() traîne au niveau module. C'est le piège d'import du niveau 05 qui se referme définitivement ici.

Erreur classique — Vouloir en profiter pour AMÉLIORER le programme (nouvelles fonctionnalités, messages plus jolis). Non : une opération de restructuration ne change PAS le comportement — c'est ce qui permet de vérifier qu'elle a réussi (mêmes menus, mêmes sorties). Les améliorations viendront après la leçon 08-2, protégées par des tests.

Variante plus difficile — Fais la même opération sur un DEUXIÈME projet du niveau 07 (le quiz s'y prête bien : séparer le tirage des questions, le calcul du score, et l'interface). La deuxième fois va deux fois plus vite — c'est le signe que le réflexe s'installe.