Solutions — série 05-4 (modules, imports et projet multi-fichiers)

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. Pour l'exercice de découpage, compare les FRONTIÈRES (quoi vit où, et pourquoi) plus que le code — un découpage différent et défendable est valable.


Exercice facile a — temperature.py + main.py

Raisonnement — Le module ne contient QUE des définitions (deux fonctions du niveau 04) ; main.py importe et utilise avec le préfixe. Prédictions : 20 * 9 / 5 + 3268.0 (la division / produit un float, ⏪ 01-2) et (98.6 - 32) * 5 / 937.0.

Solution

temperature.py :

"""Temperature conversion helpers."""

def to_fahrenheit(celsius):
    return celsius * 9 / 5 + 32

def to_celsius(fahrenheit):
    return (fahrenheit - 32) * 5 / 9

main.py :

import temperature

print(f"20 °C = {temperature.to_fahrenheit(20)} °F")
print(f"98.6 °F = {temperature.to_celsius(98.6)} °C")
> python main.py
20 °C = 68.0 °F
98.6 °F = 37.0 °C

Pourquoi ça marcheimport temperature trouve temperature.py à côté du script lancé, l'exécute une fois (deux def, rien d'autre : silence total) et rend ses fonctions accessibles via le préfixe. Le module est réutilisable tel quel par n'importe quel autre programme du dossier.

Erreur classique sur cet exerciceimport temperature.pyModuleNotFoundError: No module named 'temperature.py'; 'temperature' is not a package : le nom de module ne prend JAMAIS l'extension. Autre classique : appeler to_fahrenheit(20) sans préfixe après un simple import temperatureNameError — le préfixe fait partie du contrat de cette forme d'import.

Variante plus difficile — Ajoute to_kelvin(celsius) (c + 273.15) et fais que main.py affiche les trois échelles pour une température saisie au clavier avec le motif de validation de 05-3 — trois leçons dans un programme de dix lignes.


Exercice facile b — if __name__ == "__main__":

Raisonnement — On ajoute les contrôles au module, protégés par le badge d'identité, et on observe les deux modes d'exécution — la démo doit être FAITE, pas juste lue.

Solution

temperature.py, complété :

"""Temperature conversion helpers."""

def to_fahrenheit(celsius):
    return celsius * 9 / 5 + 32

def to_celsius(fahrenheit):
    return (fahrenheit - 32) * 5 / 9

if __name__ == "__main__":
    # manual checks: only run by "python temperature.py", never on import
    print(to_fahrenheit(20))     # expect 68.0
    print(to_celsius(98.6))      # expect 37.0
> python temperature.py
68.0
37.0

> python main.py
20 °C = 68.0 °F
98.6 °F = 37.0 °C          <- les contrôles ne s'affichent PAS
# Pourquoi : lancé directement, __name__ vaut "__main__" -> le if est vrai.
# Importé par main.py, __name__ vaut "temperature" -> le bloc est sauté.
# Même fichier, deux identités selon COMMENT il est utilisé.

Pourquoi ça marche — Python assigne __name__ avant d'exécuter le fichier : "__main__" pour le script lancé, le nom du module pour un import. Le if compare cette valeur — c'est du Python ordinaire, aucune magie : tu peux même faire print(__name__) pour le voir.

Erreur classique sur cet exercice — Écrire les contrôles au niveau du fichier SANS le if : python main.py affiche alors 68.0 et 37.0 avant son propre affichage — l'import a exécuté les contrôles. C'est l'erreur fréquente n°2 de la leçon, ici en démonstration contrôlée. Aussi : les fautes de frappe __main___, "main", __name__ = "__main__" (un seul = !) — copie l'idiome exactement.

Variante plus difficile — Remplace les contrôles par de vraies vérifications : if to_fahrenheit(20) != 68.0: print("FAIL to_fahrenheit"). Silence = tout va bien. Tu viens d'inventer le test automatisé artisanal — pytest fera exactement ça en mieux (⏩ niveau 08).


Exercice facile c — from ... import et deux erreurs provoquées

Raisonnement — Même programme, forme d'import sélective ; puis deux sabotages volontaires pour apprendre à distinguer les DEUX messages d'échec d'import.

Solution

main.py, réécrit :

from temperature import to_fahrenheit, to_celsius

print(f"20 °C = {to_fahrenheit(20)} °F")       # no prefix with this form
print(f"98.6 °F = {to_celsius(98.6)} °C")

Les deux sabotages et leurs messages (vérifiés) :

# (1) faute de frappe dans le nom du MODULE :
from temperatur import to_fahrenheit
# ModuleNotFoundError: No module named 'temperatur'

# (2) fonction inexistante dans un module bien trouvé :
from temperature import to_farenheit
# ImportError: cannot import name 'to_farenheit' from 'temperature'
# (C:\...\temperature.py). Did you mean: 'to_fahrenheit'?

Pourquoi ça marche — Deux échecs à deux étapes différentes : ModuleNotFoundError = le FICHIER est introuvable (étape « chercher la boîte ») ; ImportError = la boîte est là mais l'outil demandé n'y est pas (étape « sortir l'outil »). Le second message donne le chemin du fichier réellement trouvé ET suggère le bon nom — le plus serviable des deux.

Erreur classique sur cet exercice — Confondre les deux messages et « corriger » au mauvais endroit : renommer le fichier quand c'est le nom de FONCTION qui est faux, ou l'inverse. Lis la PREMIÈRE ligne du message : No module named → problème de fichier ; cannot import name → problème de contenu.

Variante plus difficile — Troisième forme : import temperature as temp puis temp.to_fahrenheit(20). À quoi sert l'alias ? Cherche dans du vrai code Python les grands classiques (import numpy as np — ⏩ niveau 09) et la règle de bon sens : un alias COURT et CONVENU, jamais fantaisiste.


Exercice moyen a — découpage en storage / ui / main

Raisonnement — On répartit la liste de courses de 05-3 selon la question à laquelle chaque fonction répond : touche au fichier → storage ; parle à l'humain → ui ; orchestre → main. Les contraintes de l'énoncé sont des tests mécaniques : grep open ne doit matcher que storage.py, grep input que ui.py (et main.py pour le menu).

Solution

storage.py :

"""Load and save the shopping list (one item per line)."""

SAVE_FILE = "courses.txt"

def load_items():
    """Return the saved list, or an empty list on first run."""
    try:
        with open(SAVE_FILE, encoding="utf-8") as f:
            items = []
            for line in f:
                items.append(line.strip())
            return items
    except FileNotFoundError:
        return []

def save_items(items):
    with open(SAVE_FILE, "w", encoding="utf-8") as f:
        for item in items:
            f.write(item + "\n")

ui.py :

"""Everything that talks to the user."""

def show_items(items):
    if not items:
        print("Liste vide.")
    for i, item in enumerate(items, start=1):
        print(f"{i}. {item}")

def ask_item():
    return input("Article à ajouter : ").strip()

def ask_index(items):
    """Ask for a 1-based item number until valid, return the 0-based index."""
    while True:
        try:
            number = int(input("Numéro à retirer : "))
        except ValueError:
            print("Entier attendu.")
            continue
        if 1 <= number <= len(items):
            return number - 1
        print(f"Numéro entre 1 et {len(items)} attendu.")

main.py :

"""Entry point: ties storage and ui together."""

import storage
import ui

def main():
    items = storage.load_items()
    while True:
        choice = input("(a)jouter, (r)etirer, (l)ister, (q)uitter : ").strip().lower()
        if choice == "a":
            item = ui.ask_item()
            if item == "":
                print("Article vide ignoré.")
                continue
            items.append(item)
            storage.save_items(items)
        elif choice == "r":
            if not items:
                print("Liste vide, rien à retirer.")
                continue
            items.pop(ui.ask_index(items))
            storage.save_items(items)
        elif choice == "l":
            ui.show_items(items)
        elif choice == "q":
            break
        else:
            print("Choix inconnu.")

if __name__ == "__main__":
    main()

Pourquoi ça marche — Comportement identique à la version monofichier : on n'a RIEN réécrit, seulement déplacé — c'est la définition d'un bon refactoring. Les flèches d'import : main → storage, main → ui, aucune autre. ask_index est dans ui (elle parle à l'humain) mais reçoit items en paramètre : elle n'a pas besoin de connaître storage — les données circulent par les paramètres et les retours (⏪ niveau 04), pas par des imports croisés.

Erreur classique sur cet exercice — Mettre items en variable partagée dans storage.py que tout le monde importe : ça « marche » puis se désynchronise (relis l'erreur fréquente n°6 de la leçon — from storage import items copie le NOM). L'état vit dans main() et circule en paramètre. Autre : garder un input() de secours dans storage.py « pour demander confirmation » — la frontière est morte, le module n'est plus testable sans clavier.

Variante plus difficile — Ajoute une commande sans toucher à storage.py (ex : « compter »), puis change le format de sauvegarde sans toucher à ui.py ni main.py (ex : préfixer chaque ligne d'un numéro). Chronomètre : si chaque changement tient dans UN fichier, ton découpage est bon — c'est le test annoncé dans la leçon.


Exercice moyen b — le détective des imports

Raisonnement — Trois pannes, trois mécanismes différents. Pour chacune : identifier l'ÉTAPE qui échoue (chercher le fichier ? le nom dedans ? l'ombre d'un module standard ?) avant de prédire le message.

Solution

# cas 1 : import tools.py
# ModuleNotFoundError: No module named 'tools.py'; 'tools' is not a package
# Python lit "tools.py" comme "le module py DANS le paquet tools" — le point
# est un séparateur de hiérarchie, pas une extension.
# Correction : import tools
# cas 2 : import helpers, fichier nommé Helpers.py
# ModuleNotFoundError: No module named 'helpers'
# L'import est sensible à la casse, même sous Windows où le système de
# fichiers ne l'est pas : Python vérifie que le nom correspond exactement.
# Correction : renommer le fichier en helpers.py (les noms de modules
# s'écrivent en minuscules — convention Python que ce piège justifie).
# cas 3 : un fichier json.py à soi + import json
# AttributeError: module 'json' has no attribute 'load' (consider renaming
# 'C:\...\json.py' since it has the same name as the standard library
# module named 'json' and prevents importing that standard library module)
# L'import "réussit"... en trouvant TON json.py (le dossier du script passe
# avant la bibliothèque standard) ; c'est l'ATTRIBUT load qui manque.
# Correction : renommer ton fichier (json_practice.py) — et supprimer le
# json.pyc éventuel dans __pycache__.

Pourquoi ça marche — Les trois messages ont été vérifiés en reproduisant chaque cas. Note la gradation : cas 1 et 2 échouent à l'étape « trouver le module » (ModuleNotFoundError), le cas 3 trouve un module — le mauvais — et échoue plus tard (AttributeError). C'est le plus sournois : l'import ne signale rien, et le message récent de Python fait tout le diagnostic à ta place — lis-le en entier.

Erreur classique sur cet exercice — Pour le cas 3, chercher le bug dans son CODE (« j'ai mal appelé load ? ») au lieu de lire la fin du message qui donne littéralement la solution. Les messages d'erreur de Python 3 moderne sont écrits pour être lus — c'est l'habitude n°1 que ce cours essaie d'installer.

Variante plus difficile — Quatrième cas à inventer : crée math.py vide et lance un fichier qui fait import math puis math.sqrt(2). Prédis, vérifie, corrige. Puis établis TA liste de noms interdits (indice : python -c "import sys; print(len(sys.stdlib_module_names))" — il y en a près de 300 ; retiens au moins math, random, json, test, string).


Exercice difficile a — réparer l'import circulaire

Raisonnement — La panne : orders veut format_price (dans customers) et customers veut order_count (dans orders). Le diagnostic de fond : format_price ne parle NI de commandes NI de clients — elle n'était à sa place dans aucun des deux. On la descend dans un helpers.py sans dépendance, et le cercle disparaît.

Solution

La panne d'abord (à reproduire — traceback vérifié) :

Traceback (most recent call last):
  File "...\main.py", line 1, in <module>
    import orders
  File "...\orders.py", line 1, in <module>
    from customers import format_price
  File "...\customers.py", line 1, in <module>
    from orders import order_count
ImportError: cannot import name 'order_count' from 'orders'
(consider renaming '...\orders.py' if it has the same name as a library you intended to import)

(La suggestion de renommage est hors sujet ici — le vrai indice est le traceback : main → orders → customers → orders, tes fichiers qui tournent en rond.)

La réparation — helpers.py (nouveau) :

"""Shared helpers with no project dependencies."""

def format_price(amount):
    return f"{amount:.2f} EUR"

orders.py :

from helpers import format_price

ORDERS = [
    {"customer": "Ada", "amount": 120.0},
    {"customer": "Ada", "amount": 35.5},
]

def order_count(customer_name):
    count = 0
    for order in ORDERS:
        if order["customer"] == customer_name:
            count += 1
    return count

def describe_order(order):
    return f"{order['customer']} - {format_price(order['amount'])}"

customers.py :

from helpers import format_price
from orders import order_count, ORDERS

def describe_customer(name):
    total = 0.0
    for order in ORDERS:
        if order["customer"] == name:
            total += order["amount"]
    return f"{name}: {order_count(name)} commande(s), total {format_price(total)}"

main.py :

import orders
import customers

print(orders.describe_order(orders.ORDERS[0]))
print(customers.describe_customer("Ada"))
> python main.py
Ada - 120.00 EUR
Ada: 2 commande(s), total 155.50 EUR

Pourquoi ça marche — Dessine les flèches : main → orders, main → customers, orders → helpers, customers → helpers, customers → orders. Aucun cycle — customers peut dépendre d'orders, c'est le cercle qui était interdit, pas la dépendance. helpers ne dépend de rien : c'est la définition d'un module « socle », importable par tous sans risque. (Le f"{amount:.2f}" force deux décimales — mini-langage de format des f-strings, à ranger dans ta boîte à outils.)

Erreur classique sur cet exercice — Les « réparations » cosmétiques qui évitent de réorganiser : déplacer l'import EN BAS du fichier ou DANS la fonction. Ça peut faire taire l'erreur (l'import n'a lieu qu'à l'appel), mais le défaut de conception reste, prêt à re-mordre — et le lecteur ne voit plus les dépendances en haut du fichier. La leçon est structurelle : un cercle d'imports = une fonction mal rangée.

Variante plus difficile — Cas plus dur : et si la fonction partagée avait VRAIMENT besoin des deux modules (ex : invoice(customer, orders)) ? Réponse : elle monte d'un étage — dans main.py ou un billing.py qui importe les deux. Règle générale : ce qui dépend de tout va EN HAUT de la pyramide d'imports, ce qui ne dépend de rien va EN BAS. Redessine la pyramide de ton gestionnaire de tâches avec cette règle.


Mini-projet — « le gestionnaire de tâches passe en multi-fichiers »

Raisonnement — Même opération que l'exercice moyen a, appliquée à la v2 du gestionnaire (fiche 06) : on ne réécrit rien, on déménage. Grille de tri : load_tasks/save_tasksstorage.py ; listing [x]/[ ], saisie du titre, saisie validée du numéro → ui.py ; menu et boucle → main.py.

Solution (squelette de référence — tes noms peuvent différer)

06-gestionnaire-de-taches/
  main.py       import storage, ui — menu, boucle, if __name__ == "__main__"
  storage.py    SAVE_FILE, load_tasks(), save_tasks(tasks) — seul open() du projet
  ui.py         show_tasks(tasks), ask_title(), ask_task_number(tasks)

Les signatures qui définissent les frontières :

# storage.py
def load_tasks(): ...            # -> list of {"title": str, "done": bool}
def save_tasks(tasks): ...       # writes, returns nothing

# ui.py
def show_tasks(tasks): ...       # prints the [x]/[ ] listing
def ask_title(): ...             # -> str (stripped, may be refused if empty)
def ask_task_number(tasks): ...  # -> 0-based index, loops until valid

# main.py
def main(): ...                  # loads once, saves after EVERY change

Les deux réponses-témoins demandées par l'énoncé : changer le format de sauvegarde → storage.py ; changer le texte du menu → main.py (ou ui.py si tu y as mis l'affichage du menu — défendable, note ta décision).

Pourquoi ça marche — Le critère de réussite est comportemental (identique à ta v2) ET structurel (chaque question « où changerais-je X ? » a une réponse en un mot). La circulation est la même pyramide que l'exercice : main en haut, storage et ui en bas, jamais de flèche horizontale entre eux — tasks circule en paramètre.

Erreur classique sur cet exercice — Profiter du déménagement pour « améliorer » en même temps : tu ne sauras plus si un bug vient du refactoring ou de l'amélioration. Discipline des pros : d'abord déménager à comportement identique (et le VÉRIFIER en rejouant tes scénarios de test de la v2), committer, puis améliorer. Deux commits, deux intentions.

Variante plus difficile — (1) Fais pareil avec le quiz interactif — sa structure imposée (load_questions, ask_question, main) se répartit presque toute seule. (2) Termine le script de rangement en version --apply, avec unique_destination dans un module helpers.py importé par organize.py — ta fonction de 05-2 devient officiellement réutilisable. (3) Question d'architecte : storage.py du gestionnaire et storage.py de la liste de courses portent le même nom — quel problème cela posera-t-il le jour où un programme voudra les deux, et que fait la notion de « paquet » (⏩ niveau 07+) pour y répondre ?