Historique de la question
Avant que Python 2.5 n'introduise l'instruction with via PEP 343, la gestion des ressources nécessitait des blocs try/finally explicites éparpillés dans les bases de code. Bien que fonctionnel, ce modèle était verbeux et sujet aux erreurs pour des scénarios simples d'acquisition et de libération de ressources. Le module contextlib a été introduit pour réduire cette surcharge en permettant aux développeurs d'écrire des gestionnaires de contexte sous forme de fonctions génératrices, en utilisant le décorateur @contextmanager pour transformer des générateurs ayant l'air séquentiels en objets satisfaisant le protocole de gestion des contextes.
Le problème
Une fonction génératrice implémente nativement le protocole d'itérateur (__iter__, __next__), et non le protocole de gestionnaire de contexte (__enter__, __exit__). Le défi fondamental réside dans le fait de combler ces protocoles distincts : lors de l'entrée dans un bloc with, le code de configuration avant le yield doit s'exécuter ; lors de la sortie, le code de nettoyage après le yield doit s'exécuter indépendamment des exceptions. De plus, les exceptions levées à l'intérieur du bloc with doivent être injectées de nouveau dans le générateur au point de suspension yield, permettant à la logique de gestion des exceptions du générateur de s'exécuter pour les opérations de nettoyage.
La solution
Le décorateur enveloppe la fonction génératrice dans une classe GeneratorContextManager (implémentée en C dans le CPython moderne). Chaque invocation crée un nouvel itérateur générateur. La méthode __enter__ appelle next() sur cet itérateur, exécutant la fonction jusqu'à l'instruction yield, et retourne la valeur yieldée pour être liée à la variable as. La méthode __exit__ reçoit les détails de l'exception ; s'il n'y a pas d'exception, elle appelle next() à nouveau pour reprendre et épuiser le générateur. Si une exception s'est produite, elle appelle la méthode throw() du générateur, injectant l'exception au point yield suspendu. Cela permet aux blocs except ou finally du générateur de gérer le nettoyage. Si throw() retourne normalement (exception attrapée), __exit__ retourne True pour supprimer l'exception ; sinon, elle se propage.
from contextlib import contextmanager @contextmanager def managed_connection(): conn = create_connection() try: print("Connection établie") yield conn except NetworkError: conn.rollback() raise finally: conn.close() print("Connection fermée") with managed_connection() as c: c.query("SELECT * FROM data")
Description du problème : Un service de traitement de données à haut débit devait gérer des fichiers temporaires lorsque les tampons en mémoire dépassaient les limites. L'implémentation héritée dupliquait la logique de création et de suppression de fichiers à travers 12 modules de traitement différents, entraînant des fuites de descripteurs de fichiers lors de conditions d'erreur extrêmes et compliquant la maintenance.
Solutions envisagées :
Des blocs try/finally manuels étaient l'approche initiale. Chaque site d'utilisation enveloppait les opérations de fichiers dans des try/finally explicites pour s'assurer que os.unlink() était appelé. Cela offrait un contrôle de flux explicite sans surcharge d'abstraction, mais s'est avéré verbeux avec huit lignes par site d'utilisation et très sujet aux erreurs. Les développeurs plaçaient parfois la logique de nettoyage dans le mauvais bloc finally, et modifier le comportement de manière cohérente à travers tous les modules était ardu lorsque des exigences de journalisation étaient ajoutées.
Un gestionnaire de contexte basé sur une classe a été envisagé comme alternative réutilisable. Une classe TempSpillFile mettrait en œuvre __enter__ pour créer le fichier et __exit__ pour le supprimer. Bien que réutilisable et respectant le protocole standard, la définition de la classe séparait visuellement la configuration du nettoyage par plusieurs lignes, nuisant à la lisibilité. Elle nécessitait également quinze lignes de code standard pour ce qui était conceptuellement un cycle de vie de ressources simple, obscurcissant la logique réelle.
L'approche du générateur avec @contextmanager était l'option finale. Une fonction génératrice temp_spill_file() créerait le fichier, le yieldait, et utiliserait try/finally pour la suppression. Cela minimisait la duplication de code et maintenait la configuration et le nettoyage adjacents dans le code source, exploitant une syntaxe de gestion des exceptions familière. Cependant, elle imposait une restriction à usage unique et le point de suspension yield pouvait confondre les développeurs s'attendant à une exécution synchrone.
Solution choisie et résultat : L'approche @contextmanager a été sélectionnée car elle minimisait la duplication de code tout en maximisant la clarté lors des revues de code. L'adjacence de la logique d'acquisition et de libération rendait le cycle de vie de la ressource immédiatement évident. Le refactoring a réduit le code de gestion des ressources de quatre-vingt-seize lignes à douze lignes dans l'ensemble de la base de code. L'analyse statique a confirmé qu'il n'y avait aucune fuite de descripteurs de fichiers lors du trimestre de production suivant.
Comment GeneratorContextManager gère-t-il les exceptions survenant lors de la phase de configuration (avant le yield) par rapport à la phase de nettoyage (après le yield) ?
Si une exception se produit avant le yield dans le générateur, le générateur ne se suspend jamais ; __enter__ propage immédiatement cette exception et __exit__ n'est jamais invoqué. Si une exception se produit au sein du bloc with (après yield), le générateur est suspendu. __exit__ appelle alors generator.throw(exc_type, exc_val, exc_tb), ce qui reprend le générateur à la ligne yield avec l'exception active. Cela permet aux blocs except ou finally du générateur de s'exécuter. Les candidats oublient souvent que throw() reprend en fait l'exécution et que l'exception est considérée comme se produisant au niveau de l'expression yield du point de vue du générateur.
Pourquoi un générateur décoré avec contextmanager impose-t-il un point de yield unique et quelle erreur spécifique se produit si cette contrainte est violée ?
Le protocole de gestionnaire de contexte suppose une seule entrée et sortie. Si le générateur yield un second fois – soit parce que __exit__ appelle next() (pas d'exception) et le générateur yield à nouveau au lieu de retourner, soit parce que throw() est appelé et que le générateur gère l'exception puis yield à nouveau – le GeneratorContextManager lève RuntimeError avec le message "le générateur ne s'est pas arrêté". Cela se produit parce que la machine d'état s'attend à ce que le générateur soit épuisé après le nettoyage. Les candidats confondent souvent cela avec une itération standard où plusieurs yields sont valides, ne réalisant pas que le yield agit comme une frontière de suspension/reprise pour le contexte, et non comme une séquence de production de valeurs.
Dans quelles circonstances la méthode __exit__ d'un GeneratorContextManager supprime-t-elle une exception levée dans le bloc with, et comment cela interagit-il avec la gestion des exceptions du générateur ?
__exit__ supprime l'exception (retourne True) uniquement si l'exception injectée via throw() est attrapée à l'intérieur du générateur et que le générateur atteint sa fin (lève StopIteration) sans relancer l'exception ou en soulever une nouvelle. Si le générateur attrape l'exception et permet à l'appel de throw() de retourner normalement, __exit__ interprète cela comme une gestion réussie et retourne True. Si le générateur ne gère pas l'exception, throw() la propage, et __exit__ retourne None (falsy), permettant à l'exception de se propager. Les candidats oublient souvent que le simple fait d'avoir un try/except à l'intérieur du générateur n'est pas suffisant ; l'exception doit être attrapée spécifiquement à partir de l'appel throw() et non relancée, et qu'un return explicite ou le fait de tomber à la fin après avoir attrapé est nécessaire pour la suppression.