Historique : PEP 343 a introduit l'instruction with dans Python 2.5, normalisant les modèles de gestion des ressources qui nécessitaient auparavant des blocs try-finally verbeux. Le protocole exige que les objets implémentent les méthodes __enter__ et __exit__, avec l'innovation critique étant la capacité de __exit__ à inspecter et à éventuellement supprimer les exceptions via sa valeur de retour. Cette conception permet des modèles de dégradation gracieuse où l'infrastructure peut gérer les échecs attendus sans les propager à la logique métier.
Problème : Lorsqu'une exception se produit dans un bloc with, Python appelle __exit__(exc_type, exc_val, exc_tb) avec les détails de l'exception active. Si cette méthode retourne une valeur véridique (évaluée comme True dans le contexte booléen), Python considère l'exception comme gérée et en supprime entièrement la propagation. Si elle retourne False, None, ou toute valeur non véridique, l'exception se propage normalement après que __exit__ se soit complété, indépendamment de la réussite du nettoyage.
Solution : Implémentez __exit__ pour retourner True seulement lorsque l'exception doit être intentionnellement supprimée, comme des erreurs de validation attendues ou des pannes de réseau transitoires. Retournez explicitement False lorsque le nettoyage est terminé mais que l'erreur doit se propager, ou retournez implicitement None en sortant de la méthode. La méthode reçoit trois arguments décrivant l'exception active, ou (None, None, None) si elle s'achève normalement.
class SuppressKeyError: def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): if exc_type is KeyError: print(f"Avalé : {exc_val}") return True # Supprimer return False # Propager les autres # Utilisation with SuppressKeyError(): raise KeyError("ignoré") # Silencieux with SuppressKeyError(): raise ValueError("propagé") # Lance
Scénario : Une équipe de développement construit un processeur de tâches distribué où les nœuds de travail acquièrent des verrous exclusifs via Redis avant d'exécuter des sections critiques. Lorsque la latence du réseau cause des exceptions LockTimeout, le système doit réessayer de manière transparente plutôt que de faire planter le processus de travail. Cependant, les erreurs fatales comme MemoryError ou les erreurs de programmation doivent se propager immédiatement pour déclencher des alertes et éviter des boucles de réessai infinies.
Problème : L'implémentation initiale a éparpillé des blocs try-except dans toute la logique métier, créant un cauchemar de maintenance et obscurcissant le véritable code du domaine. Le défi consiste à centraliser ce mécanisme de suppression sélective sans violer le principe selon lequel les préoccupations d'infrastructure ne doivent pas polluer le code du domaine.
Solution 1 : Enveloppez chaque exécution de tâche dans des blocs try-except imbriqués explicites au site d'appel. Avantages : Le flux de contrôle est immédiatement visible pour les lecteurs de la logique métier, rendant le débogage simple pour les nouveaux membres de l'équipe. Inconvénients : Cette approche viole le principe DRY en répétant la logique de réessai partout, couplant étroitement le code métier aux détails de l'infrastructure, et rendant les tests unitaires difficiles car les tests doivent simuler des échecs de verrou à chaque site d'appel plutôt que de simuler un seul gestionnaire de contexte.
Solution 2 : Créez un gestionnaire de contexte DumbSuppressor qui retourne inconditionnellement True de __exit__. Avantages : L'implémentation nécessite seulement deux lignes de code et élimine complètement le code d'enveloppe de gestion des exceptions dans la logique métier. Inconvénients : Cela avale dangereusement toutes les exceptions y compris les erreurs système critiques et les bogues de programmation, menant à des échecs silencieux et des états d'application indéfinis qui sont impossibles à déboguer dans des environnements de production.
Solution 3 : Implémentez SmartRetryContext qui inspecte exc_type contre une liste blanche configurable d'exceptions transitoires. Avantages : Cela centralise la logique de réessai de manière déclarative, permet un contrôle précis sur quelles erreurs déclenchent un réessai versus une propagation immédiate, et maintient une séparation claire entre la logique métier et les préoccupations d'infrastructure. Inconvénients : La liste blanche nécessite un entretien attentif pour éviter de supprimer accidentellement des erreurs inattendues qui indiquent de vrais bogues plutôt que des problèmes d'infrastructure transitoires.
Approche choisie : L'équipe a sélectionné la solution 3 parce qu'elle équilibre la sécurité et la fonctionnalité. La méthode __exit__ vérifie issubclass(exc_type, RetriableException) et retourne True seulement pour des échecs transitoires comme les délais d'attente réseau, tout en permettant aux erreurs de programmation de se manifester immédiatement pour le débogage.
Résultat : Le système gère gracieusement les pics de latence de Redis en réessayant automatiquement, tout en se plantant adéquatement pour les erreurs. Les tableaux de bord de surveillance ont montré une réduction de 40 % du bruit d'alerte provenant des échecs transitoires, et les développeurs pouvaient écrire la logique des tâches sans se soucier des détails d'acquisition des verrous.
Question : Quelle est la différence entre le comportement de la méthode __exit__ de Python lorsqu'elle retourne None contre lorsqu'elle retourne False, et pourquoi les deux entraînent une propagation d'exception malgré le fait que None soit non véridique ?
Réponse. Beaucoup de candidats croient incorrectement que retourner None indique "aucune opinion" tandis que False demande activement la propagation. Dans Python, les deux valeurs sont non véridiques dans le contexte booléen, et le protocole vérifie explicitement if not exit_return_value: propagate_exception(). Par conséquent, None et False se comportent de manière identique : l'exception se propage dans les deux cas. La distinction est seulement importante pour la lisibilité du code ; False indique une propagation intentionnelle tandis que None indique une omission accidentelle.
Question : Si la méthode __exit__ de Python supprime intentionnellement une exception en retournant True, mais soulève ensuite une nouvelle exception lors de sa logique de nettoyage, qu'est-ce qui détermine quelle exception se propage vers le scope extérieur ?
Réponse. La nouvelle exception soulevée dans __exit__ remplace complètement l'originale. Python évalue d'abord la valeur de retour de __exit__ ; si elle est véridique, elle se prépare à supprimer l'exception originale. Cependant, si __exit__ lui-même soulève avant de retourner, cette nouvelle exception se propage à la place, et l'exception originale est perdue à moins d'être explicitement chaînée en utilisant raise NewException from original. Cela diffère des blocs finally, où les exceptions dans le bloc finally remplacent mais peuvent être chaînées avec l'exception active.
Question : Dans quelle condition Python garantit que __exit__ ne sera pas invoqué même après que __enter__ ait été entré, et comment cela diffère-t-il des garanties des blocs finally ?
Réponse. Si __enter__ soulève une exception, Python n'invoque jamais __exit__ car le contexte n'a jamais été établi avec succès. Cela contraste fortement avec la sémantique des try-finally, où le bloc finally s'exécute même si le bloc try soulève immédiatement après l'entrée. Cette distinction est cruciale pour la gestion des ressources : les ressources allouées partiellement dans __enter__ avant un échec doivent être nettoyées dans __enter__ lui-même à l'aide de try-finally, car __exit__ ne sera pas exécuté pour les nettoyer.