Py module en Python : bonnes pratiques pour un code maintenable

Un projet Python qui démarre avec un seul fichier main.py finit souvent par exploser en dizaines de fichiers sans organisation claire. Le moment où on ne sait plus si la fonction parse_email vit dans utils.py, helpers.py ou tools.py, c’est le signe qu’on a raté le découpage en modules. Structurer son code en py module bien découpés dès le départ évite des semaines de refactoring six mois plus tard.

Arborescence d’un package Python : partir du cas d’usage, pas de la théorie

On voit souvent des tutoriels qui montrent une arborescence type avec src/, tests/, docs/ et un fichier __init__.py dans chaque répertoire. Le problème, c’est que cette structure ne dit rien sur la logique métier du projet.

A découvrir également : Code Pampers : mode d'emploi pour en profiter facilement

Prenons un cas concret : une application qui récupère des données utilisateur, les valide, puis envoie un email de confirmation. Trois responsabilités distinctes, trois modules possibles.

  • user/fetch.py contient les fonctions qui interrogent la base ou l’API pour récupérer les données d’un user
  • user/validate.py regroupe les règles de validation (format email, champs obligatoires, cohérence des data)
  • notification/email.py gère l’envoi du message, le template, la connexion SMTP

Chaque fichier Python devient un module. Chaque répertoire contenant un __init__.py devient un package. Le découpage suit les responsabilités, pas les types d’objets (pas de models.py fourre-tout de 800 lignes).

A découvrir également : Comment optimiser le système de paie en entreprise ?

Le fichier __init__.py à la racine de chaque package sert à exposer l’interface publique. On y place les import qui simplifient l’usage depuis l’extérieur : from user import fetch_user plutôt que from user.fetch import fetch_user_from_db_v2.

Ingénieur logiciel travaillant sur l'organisation de modules Python dans un open space technologique moderne

Import circulaire entre modules Python : le piège classique

Sur un projet qui grossit, on finit presque toujours par tomber sur une ImportError liée à un import circulaire. Le module A importe le module B, qui importe le module A. Python lève une erreur ou, pire, importe un module partiellement initialisé sans prévenir.

La cause est rarement technique. C’est un problème de conception : deux modules qui dépendent mutuellement partagent probablement une responsabilité mal découpée.

Trois stratégies pour casser le cycle

La première consiste à extraire le code partagé dans un troisième module. Si user.py et email.py utilisent tous les deux une fonction format_address, on la déplace dans un module formatting.py que les deux importent sans dépendance croisée.

La deuxième approche : déplacer l’import à l’intérieur de la fonction qui en a besoin (import local). Ça fonctionne, mais c’est un pansement. On l’utilise quand le refactoring du module n’est pas possible immédiatement.

La troisième, souvent négligée, consiste à se demander si les deux fichiers ne devraient pas fusionner. Un module de 200 lignes bien organisé vaut mieux que deux modules de 100 lignes qui ne peuvent pas vivre l’un sans l’autre.

Fichier __init__.py et exposition de l’API publique d’un package

Le contenu du fichier __init__.py conditionne l’expérience de quiconque utilise le package. Un __init__.py vide oblige l’utilisateur à connaître la structure interne du répertoire pour importer quoi que ce soit. Un __init__.py surchargé de logique métier rend le package difficile à déboguer.

La bonne pratique tient en une règle : n’y mettre que des imports et éventuellement __all__. La variable __all__ définit explicitement ce qui est exporté quand quelqu’un écrit from package import *. Sans elle, Python exporte tout ce qui ne commence pas par un underscore, y compris des fonctions internes qu’on ne voulait pas rendre publiques.

Exemple pour un package math_utils :

__init__.py contient from .operations import add, multiply et __all__ = ["add", "multiply"]. Le fichier operations.py contient les fonctions def add, def multiply, mais aussi une fonction def _round_internal qui reste privée.

Cette approche permet de refactorer librement la structure interne (renommer un fichier, déplacer une fonction) sans casser le code qui dépend du package, parce que l’interface publique reste stable dans __init__.py.

Deux développeurs en session de revue de code Python examinant la structure de modules dans une salle de réunion vitrée

Nommer et organiser ses modules pour un import lisible

Le nommage des modules a un impact direct sur la lisibilité du code qui les consomme. Quand on lit from data.cleanup import remove_duplicates, on comprend immédiatement ce que fait la fonction et d’où elle vient. Quand on lit from utils import func3, on ne comprend rien.

Quelques contraintes de nommage qui évitent les problèmes :

  • Un nom de module en minuscules, sans tiret (underscore autorisé). data_fetch.py fonctionne, data-fetch.py provoque une erreur à l’import
  • Ne jamais nommer un module comme un module de la bibliothèque standard (math.py, email.py, json.py). Python risque d’importer le fichier local au lieu du module standard, avec des erreurs incompréhensibles
  • Préférer des noms courts et spécifiques. parse.py dit ce que fait le module. helpers.py ne dit rien
  • Un module qui dépasse 300-400 lignes mérite d’être scindé. Ce n’est pas une règle absolue (les retours varient sur ce point), mais au-delà, la navigation dans le fichier devient pénible

Pour les projets destinés à être publiés sur PyPI, le nom du package (le répertoire racine) doit être unique. Vérifier sur pypi.org avant de choisir un nom évite de devoir tout renommer au moment du setup.py ou du pyproject.toml.

Tester un module Python en isolation

Un module bien découpé se teste facilement. Si pour tester une fonction def validate_email, on doit instancier une connexion à la base de données, c’est que le module mélange validation et accès aux data.

La structure en modules séparés par responsabilité rend les tests unitaires directs. On importe la fonction, on lui passe des arguments, on vérifie le return. Pas besoin de mock complexe quand chaque module a une seule raison de changer.

Pour lancer les tests d’un seul module sans exécuter toute la suite, la commande python -m pytest tests/test_validate.py suffit. Le flag -m garantit que Python résout correctement les imports relatifs depuis la racine du projet, ce qui évite les ModuleNotFoundError qui découragent les développeurs juniors.

Le découpage en modules Python n’a rien de cosmétique. C’est ce qui détermine si un projet reste navigable quand l’équipe passe de deux à dix personnes, ou quand on revient sur le code après plusieurs mois. Un bon __init__.py, des noms de fichiers explicites et des imports sans cycle suffisent à garder un projet sous contrôle sans outillage supplémentaire.