Leçon 05-4 — Modules, imports et projet multi-fichiers

1. Objectif

À la fin de cette leçon tu sauras : importer un module de la bibliothèque standard (import math) comme tes propres fichiers .py, choisir entre import module et from module import nom, protéger le code de test avec if __name__ == "__main__":, découper un programme en modules (un module = une responsabilité), et diagnostiquer un ModuleNotFoundError ou un import circulaire en moins de cinq minutes.

2. Pourquoi c'est important

Tous tes programmes tiennent aujourd'hui dans UN fichier. Ça ne durera pas : au-delà de ~150 lignes, un fichier unique devient une soupe où tout se cherche. Les modules sont la réponse de Python : chaque fichier .py EST un module, importable depuis un autre. C'est aussi la porte d'entrée vers la bibliothèque standard (des centaines de modules livrés avec Python : math, random, datetime, json…) — tu utilises from pathlib import Path depuis deux leçons sans qu'on ait expliqué cette ligne ; aujourd'hui, on l'explique. Enfin, savoir organiser un projet multi-fichiers est LA compétence qui sépare « des scripts » d'« un logiciel » — les niveaux 06 à 10 travaillent tous dans des projets à plusieurs fichiers.

3. Explication simple

Un module, c'est une boîte à outils : un fichier .py rempli de fonctions (et de variables). import va chercher la boîte ; le point . ouvre la boîte :

import math                 # va chercher la boîte "math"

print(math.sqrt(16.0))      # 4.0  — l'outil sqrt DE la boîte math
print(math.pi)              # 3.141592653589793

Deuxième forme, from ... import : sortir UN outil de la boîte pour l'utiliser directement, sans préfixe :

from math import sqrt

print(sqrt(16.0))           # 4.0 — plus court, mais on ne sait plus d'où il vient

Et le point crucial : tes fichiers marchent pareil. Si tu as écrit geometry.py avec une fonction rectangle_area(), alors depuis un autre fichier du même dossier :

import geometry
print(geometry.rectangle_area(3, 4))    # 12

Le nom du module, c'est le nom du fichier sans le .py. Écrire un module, tu sais déjà le faire — c'est juste des fonctions dans un fichier.

4. Explication approfondie

a) Importer, c'est EXÉCUTER. import geometry ne « charge » pas passivement le fichier : Python exécute geometry.py de haut en bas, une fois, et garde le résultat (les fonctions définies, les variables créées). Conséquence directe : si le module contient des print() ou des input() au niveau du fichier, ils se déclenchent À L'IMPORT. C'est pourquoi un module bien élevé ne contient que des définitions… sauf, parfois, un bloc de test — voir (c).

b) Où Python cherche les modules. Dans l'ordre : (1) la bibliothèque standard, (2) le dossier du script lancé — pas le dossier courant du terminal, celui du .py que tu exécutes — et quelques autres endroits (⏩ les paquets installés avec pip, niveau 09). D'où la règle d'or des projets : tous les fichiers du projet dans un même dossier (la racine), et on lance toujours le script principal depuis cette racine. Fichiers de données et modules restent alors trouvables ensemble — souviens-toi de 05-2 : les chemins relatifs, EUX, partent bien du dossier courant du terminal. Les deux règles se rejoignent : lance depuis la racine.

c) if __name__ == "__main__": — le badge d'identité. Python donne à chaque fichier une variable automatique __name__. Sa valeur dépend de COMMENT le fichier est utilisé :

D'où l'idiome, à la fin d'un module :

if __name__ == "__main__":
    # ce bloc ne tourne QUE lancé directement, jamais à l'import
    print(rectangle_area(3, 4))     # petits tests manuels du module

Double usage : (1) mettre des essais dans un module sans polluer ceux qui l'importent ; (2) marquer le point d'entrée du programme principal — l'idiome complet étant def main(): + if __name__ == "__main__": main(). Tu verras cette paire de lignes dans quasiment tout code Python réel.

d) import x ou from x import y ? Les deux sont corrects ; le choix est affaire de lisibilité :

e) Un module = une responsabilité. Le découpage type d'un petit programme à trois fichiers :

mon-projet/
  main.py       point d'entrée : assemble, boucle principale — le chef d'orchestre
  storage.py    tout ce qui touche au fichier de sauvegarde (load/save)
  ui.py         tout ce qui parle à l'humain (menus, saisies, affichages)

Le test du bon découpage : sauras-tu dire en un mot ce que fait chaque fichier ? Et la circulation va dans UN sens : main importe storage et ui ; storage et ui ne s'importent pas entre eux et n'importent jamais main. Quand deux modules s'importent mutuellement, Python tourne en rond — c'est l'import circulaire (section 7), et c'est presque toujours le signe qu'une fonction est dans le mauvais fichier.

Difficulté honnête : la mécanique d'import est simple, et le piège n'est pas où tu crois : c'est le découpage qui demande du métier — quoi va où, comment nommer. Tes premiers découpages seront discutables, c'est attendu ; celui de l'exercice moyen est guidé pour te donner un modèle. Et quand un import échoue, ne « bidouille » pas au hasard : la section 7 donne la checklist de diagnostic, elle couvre 95 % des cas.

5. Exemples commentés

Exemple 1 — la bibliothèque standard, enfin expliquée

import math
import random
from pathlib import Path        # la ligne que tu écris depuis la leçon 05-2

print(math.sqrt(2.0))           # 1.4142135623730951
print(random.randint(1, 6))     # un dé : 1 à 6 (change à chaque lancement)
print(Path.cwd())               # pathlib est un module standard comme un autre

Convention : tous les imports en haut du fichier, un module par ligne. On repère ainsi d'un coup d'œil de quoi dépend un fichier.

Exemple 2 — ton premier module à toi

geometry.py :

"""Small geometry helpers."""

def rectangle_area(width, height):
    return width * height

def rectangle_perimeter(width, height):
    return 2 * (width + height)

main.py, dans le même dossier :

import geometry

print(geometry.rectangle_area(3, 4))         # 12
print(geometry.rectangle_perimeter(3, 4))    # 14
> python main.py
12
14

Rien de neuf dans geometry.py — des fonctions du niveau 04. La nouveauté tient en une ligne : import geometry rend TOUT le fichier disponible ici.

Exemple 3 — voir __name__ de ses propres yeux

badge.py :

print("badge.py exécuté, __name__ vaut :", __name__)
> python badge.py
badge.py exécuté, __name__ vaut : __main__

> python -c "import badge"
badge.py exécuté, __name__ vaut : badge

Le même fichier, deux identités : "__main__" en direct, son nom de module à l'import. Remarque au passage que l'import a EXÉCUTÉ le print — point (a) démontré.

Exemple 4 — le module avec ses tests embarqués

geometry.py, version complète :

"""Small geometry helpers."""

def rectangle_area(width, height):
    return width * height

def rectangle_perimeter(width, height):
    return 2 * (width + height)

if __name__ == "__main__":
    # manual checks: run "python geometry.py" to execute them
    print(rectangle_area(3, 4))         # expect 12
    print(rectangle_perimeter(3, 4))    # expect 14

python geometry.py affiche les deux vérifications ; import geometry ne les affiche PAS. Le module est à la fois une boîte à outils silencieuse et son propre banc d'essai — c'est l'ancêtre artisanal des tests automatisés (⏩ pytest, niveau 08).

Exemple 5 — un vrai projet à trois modules

La liste de courses de 05-3, découpée. storage.py :

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

SAVE_FILE = "courses.txt"

def load_items():
    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()

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, (l)ister, (q)uitter : ").strip().lower()
        if choice == "a":
            items.append(ui.ask_item())
            storage.save_items(items)
        elif choice == "l":
            ui.show_items(items)
        elif choice == "q":
            break

if __name__ == "__main__":
    main()

Lis la circulation : main importe les deux autres ; storage et ui s'ignorent mutuellement. Chaque fichier répond à sa question — « où sont les données ? » → storage.py, « comment on parle à l'humain ? » → ui.py, « quel est le scénario ? » → main.py. Pour changer le format de sauvegarde, tu n'ouvres QU'un fichier.

Exemple 6 — importer sélectivement

from storage import load_items, save_items    # les deux noms, sans préfixe

items = load_items()
save_items(items)

Équivalent au import storage de l'exemple 5, en plus court. Bon compromis dans main.py quand les noms sont clairs. Si deux modules exportaient chacun un load_items, on reviendrait au préfixe — le conflit de noms est la limite naturelle du from.

6. Erreurs fréquentes

1) Nommer son fichier comme un module standard

# tu as créé un fichier random.py, et ailleurs :
import random
print(random.randint(1, 6))
# AttributeError: module 'random' has no attribute 'randint'

Ton random.py fait de l'ombre au vrai module random : Python trouve LE TIEN d'abord (le dossier du script passe avant la bibliothèque standard). Symptômes déroutants garantis ; le message d'erreur des versions récentes suggère d'ailleurs de renommer. Ne nomme jamais un fichier random.py, math.py, json.py, test.py

2) Du code exécutable au niveau du module

# storage.py — au niveau du fichier, hors de toute fonction :
items = load_items()
print(f"{len(items)} articles chargés")

Chaque import storage déclenche chargement et affichage — y compris quand l'importeur n'en voulait pas. Un module définit ; il n'AGIT qu'à la demande (dans ses fonctions) ou dans son bloc if __name__ == "__main__":.

3) Réimporter « pour recharger » dans la même session

Tu modifies geometry.py, tu refais import geometry dans le REPL… et rien ne change. Python n'importe qu'une fois par session et garde le module en cache ; les imports suivants sont ignorés. Solution simple : relancer le programme (ou le REPL). C'est aussi pour ça que l'exemple 5 peut importer storage depuis plusieurs fichiers sans le « ré-exécuter » à chaque fois.

4) L'import qui marche… grâce au hasard du dossier courant

> cd mon-projet
> python main.py          ✅
> cd ..
> python mon-projet/main.py    ✅ les IMPORTS marchent encore (dossier du script)...
                               💥 mais open("courses.txt") cherche ailleurs !

Piège subtil vu en (b) : les imports suivent le dossier DU SCRIPT, les chemins relatifs de fichiers suivent le dossier COURANT. Lancé « de loin », le programme importe bien ses modules mais lit/écrit ses fichiers au mauvais endroit — ou les recrée vides ! D'où LA règle simple : on lance depuis la racine du projet, toujours. (Le blindage Path(__file__).parent de 05-2 protège aussi les fichiers de données — les deux pratiques se complètent.)

5) L'import circulaire

# alpha.py                        # beta.py
from beta import from_beta        from alpha import from_alpha

python alpha.pyImportError: cannot import name 'from_beta' from 'beta' (traceback en boucle : alpha → beta → alpha). Python exécute alpha.py, tombe sur l'import de beta, commence beta.py… qui redemande alpha encore à moitié vide. Ce n'est pas une subtilité à contourner, c'est un défaut de découpage : la fonction partagée doit descendre dans un troisième module (helpers.py) que les deux importent — la circulation redevient à sens unique.

6) Modifier une copie du nom au lieu du module

from config import counter    # counter est copié ICI au moment de l'import
counter = counter + 1         # ne modifie QUE la copie locale !

from x import y copie le nom dans ton fichier : réassigner cette copie ne touche pas la variable du module. Pour un état partagé, garde le préfixe (config.counter)… ou mieux : évite les variables partagées entre modules — passe les valeurs en paramètres, comme au niveau 04.

7. Lire les messages d'erreur

L'erreur signature de la leçon :

Traceback (most recent call last):
  File "C:\Users\jtron\Claude\learnpython\lessons\level-05-fichiers-exceptions-modules\exercices\mes-reponses\main.py", line 1, in <module>
    import storag
ModuleNotFoundError: No module named 'storag'

ModuleNotFoundError = « je n'ai trouvé ce module NULLE PART ». Checklist de diagnostic, dans l'ordre :

  1. Faute de frappe ? Compare lettre à lettre le nom importé et le nom du fichier (ici : storag vs storage.py).
  2. Le fichier est-il dans le bon dossier ? Le module doit être à côté du script lancé (ou dans la bibliothèque standard).
  3. D'où as-tu lancé ? python sans fichier (REPL) cherche dans le dossier courant : si tu as ouvert le REPL ailleurs, tes modules sont invisibles.
  4. Le nom importé ne prend jamais le .py : import storage, pas import storage.py (ce dernier chercherait un module py DANS un paquet storage…).

Sa cousine, quand le module existe mais pas le nom demandé :

ImportError: cannot import name 'load_item' from 'storage'
(C:\Users\jtron\Claude\learnpython\...\storage.py). Did you mean: 'load_items'?

Précieuse : elle donne le chemin du fichier réellement trouvé (vérifie que c'est le bon !) et suggère le nom probable. Enfin, l'import circulaire se reconnaît à son traceback en boucle — les mêmes fichiers qui se succèdent — et à son message :

ImportError: cannot import name 'from_beta' from 'beta'
(consider renaming '...beta.py' if it has the same name as a library you intended to import)

Deux causes possibles, que le message regroupe : soit le cercle alpha → beta → alpha (regarde le traceback : tes DEUX fichiers y alternent), soit l'erreur fréquente n°1 (ton fichier masque un module standard — d'où la suggestion de renommage).

8. Exercices — faciles

Règles : cette série se fait dans un sous-dossier par exercice (mes-reponses/ex-05-4-a/, etc.), puisqu'on crée PLUSIEURS fichiers. Lance toujours depuis le dossier de l'exercice.

a) Crée temperature.py : deux fonctions, to_fahrenheit(celsius) et to_celsius(fahrenheit) (formules : c * 9 / 5 + 32 et (f - 32) * 5 / 9). Puis main.py qui l'importe (forme import temperature) et affiche la conversion de 20 °C et de 98.6 °F. Prédis les résultats avant de lancer.

b) Ajoute à temperature.py un bloc if __name__ == "__main__": affichant deux conversions de contrôle. Vérifie les DEUX comportements : python temperature.py (les contrôles s'affichent) puis python main.py (ils ne s'affichent PAS). Explique en commentaire pourquoi.

c) Réécris main.py avec la forme from temperature import to_fahrenheit, to_celsius. Puis provoque volontairement, et note en commentaire le message obtenu : (1) un import avec faute de frappe dans le nom du MODULE ; (2) un import d'une fonction qui n'existe pas dans le module. Deux erreurs différentes — lesquelles ?

9. Exercices — moyens

a) Découpe ton quiz ou ta liste de courses (exercice difficile de 05-3) en trois modules : storage.py (charger/sauver), ui.py (afficher/saisir), main.py (le scénario + if __name__ == "__main__":). Contraintes : aucun print/input dans storage.py, aucun open() hors de storage.py, aucun code exécutable au niveau des modules, et le comportement ne change pas d'un iota. (L'exemple 5 est ton modèle — mais ta version doit garder TOUTES tes fonctionnalités : retirer, valider…)

b) Le détective des imports. Sans exécuter, diagnostique ces trois pannes (chaque réponse : le message d'erreur attendu + la correction) :

# cas 1 : fichiers tools.py et main.py côte à côte ; main.py contient :
import tools.py

# cas 2 : main.py importe "helpers" ; le fichier s'appelle Helpers.py

# cas 3 : tu crées un fichier json.py pour t'entraîner, et plus loin :
import json
data = json.load(f)

Vérifie ensuite tes trois prédictions en reproduisant chaque cas dans un sous-dossier.

10. Exercices — difficiles

a) Répare un import circulaire. Crée d'abord la panne : orders.py a besoin de format_price() qui est dans customers.py, et customers.py a besoin de order_count() qui est dans orders.py — écris les deux fichiers avec un from ... import dans chaque, et un main.py qui importe orders ; constate l'ImportError. Puis répare SANS supprimer de fonctionnalité, en réorganisant les modules.

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

Reprends ton gestionnaire de tâches (projects/mini-projects/06-gestionnaire-de-taches.md, v2 faite en 05-3) et réorganise-le en projet propre (~30–45 min) :

06-gestionnaire-de-taches/
  main.py       menu et boucle principale, if __name__ == "__main__"
  storage.py    load_tasks() / save_tasks(tasks) — seul à toucher au fichier
  ui.py         affichage du listing [x]/[ ], saisies validées

Critère de réussite : le comportement est identique à ta v2, et tu peux répondre en un mot à « où changerais-tu le format de sauvegarde ? », « où changerais-tu le texte du menu ? ». Même exercice possible sur le quiz interactif (load_questionsstorage.py, ask_questionui.py) — sa structure imposée par la fiche s'y prête déjà. Enfin, si tu as fait la simulation du script de rangement en 05-2, tu as maintenant tout pour terminer sa version --apply : gestion d'erreurs (05-3) + unique_destination en module réutilisable (05-4).

12. Correction / méthode de correction

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

Méthode : (1) finis TOUS les exercices avant d'ouvrir les solutions ; (2) pour l'exercice de découpage, compare les FRONTIÈRES (qu'as-tu mis où, et pourquoi ?) plus que le code — un découpage différent et défendable est valable ; (3) vérifie le sens des flèches d'import dans tes projets (jamais de boucle, jamais vers main) ; (4) une ligne de journal par écart ; (5) refais de tête le lendemain.

13. À retenir

14. Questions de révision

  1. Que se passe-t-il exactement, ligne par ligne, quand Python exécute import geometry pour la première fois ? Et la deuxième fois ?
  2. Quand préfères-tu import storage à from storage import load_items, et inversement ?
  3. Que vaut __name__ dans un fichier lancé directement ? Importé ? Et à quoi sert le bloc if __name__ == "__main__": dans un module outil ?
  4. Pourquoi appeler un fichier random.py casse-t-il random.randint — par quel mécanisme précis ?
  5. Ton programme multi-fichiers marche lancé depuis son dossier, mais lancé d'ailleurs il recrée un fichier de sauvegarde vide. Explique les DEUX règles de recherche différentes en jeu.
  6. À quoi reconnaît-on un import circulaire dans un traceback, et quelle est la réparation de fond ?
  7. Dans le découpage main/storage/ui : où vivent les input() ? Les open() ? Pourquoi cette séparation paie-t-elle ?
  8. Pourquoi from config import counter puis counter += 1 ne modifie-t-il pas la valeur vue par les autres modules ?

15. Checklist de compréhension

Si une case reste vide : refais les exercices de la section correspondante demain, AVANT de passer au niveau suivant.

16. Commit conseillé

git add lessons/level-05-fichiers-exceptions-modules/exercices/mes-reponses/
git commit -m "exercises: complete lesson 05-4 modules and multi-file projects"

Puis, si c'est ta fin de session : entrée de journal + git push. C'est aussi la fin du niveau 05 — relis les critères de sortie du README du niveau avant d'attaquer la POO.