ExitStack en Python

ExitStack est :

  • une pile (stack) de fonctions exécutées à la sortie (exit)
  • implémentée sous forme de gestionnaire de contexte

Le principe est d'ajouter des fonctions de rappel (callbacks) à cette pile.

La pile est garantie d'être exécutée au moment de quitter le bloc with.

Ça permet de combiner plusieurs gestionnaires de contexte ou des fonctions de nettoyage.

Acquérir plusieurs ressources

La gestion correcte des ressources externes (fichiers, verrous, connexions, etc.) est un problème délicat.

Elles doivent être libérées quand on en a plus besoin pour éviter des leaks, et ce dans tous les chemins d'exécution alternatifs pouvant être empruntés en cas d'erreur.

ExitStack permet d'acquérir plusieurs ressources en exécutant les fonctions de nettoyage à la sortie du bloc with :

with ExitStack() as stack:

    res1 = acquire_resource_one()
    stack.callback(release_resource, res1)
    # Do stuff with res1…

    res2 = acquire_resource_two()
    stack.callback(release_resource, res2)
    # Do stuff with res1 and res2…

On note que :

  • le code d'acquisition et le code de libération des ressources sont proches
  • le modèle s'adapte facilement à de nombreuses ressources (y compris un nombre dynamique)

Empiler des gestionnaires de contexte

enter_context() permet d'ajouter un gestionnaire de contexte préexistant dans la pile sans instruction with explicite supplémentaire.

Ici, le gestionnaire de contexte open fait déjà le nettoyage de façon implicite :

with ExitStack() as stack:
    res1 = stack.enter_context(open("a.tx"))
    # Do stuff with res1…
    res2 = stack.enter_context(open("b.tx"))
    # Do stuff with res1 and res2…

La méthode __exit__() de chaque sous-gestionnaire de contexte open est déclenchée en sortie du contexte d'ExitStack : le nettoyage est garanti à la sortie.

Travailler proprement avec un groupe de fichiers

Pour tenter d'ouvrir un groupe de fichiers en s'assurant qu'en cas d'échec d'ouverture de l'un d'eux, ceux déjà ouverts soient garantis d'être fermés :

from contextlib import ExitStack
from typing import Callable

def open_files(filenames: list) -> tuple[list, Callable]:
    with ExitStack() as stack:
        files = [stack.enter_context(open(name)) for name in filenames]
        return files, stack.pop_all().close

try:
    files, cleaner = open_files(["a.txt", "b.txt", "c.txt"])        
    for f in files:
        print(f.read())
    cleaner()
except FileNotFoundError as err:
    print(f"Caught error {err}.")

Si tout se passe bien, la méthode pop_all() :

  1. efface tous les gestionnaires de contexte et les callbacks de la pile sur laquelle elle est appelée
  2. retourne une nouvelle pile préremplie avec ces mêmes gestionnaires de contexte et callbacks

Dans ce cas, aucun des fichiers n'est fermé et la méthode close() de la nouvelle pile peut être invoquée plus tard pour nettoyer les ressources.

Sources

Avant Toujours utiliser des intervalles [fermés, ouverts) Après 20 ans de blog

Tag Kemar Joint