Aller au contenu
BackPython

Au-delà du "with": les gestionnaires de contexte

Plongeons dans les gestionnaires de contexte en Python. L'instruction with n'aura plus de secret pour vous !

Photo by Maarten van den Heuvel / Unsplash

Si vous avez déjà codé en Python, il est fort probable que vous ayez utilisé le mot-clé with de cette manière :

with open("sfeir.txt") as file:  
	data = file.read()
	print(data)

Cependant, le fonctionnement précis de l'instruction with reste souvent méconnu. En jetant un regard en coulisses et en explorant les gestionnaires de contexte sous-jacents, nous allons découvrir les mécanismes qui donnent vie à cette instruction.

Contexte ?

L'instruction with offre une méthode simple et puissante pour gérer les ressources à l'aide de gestionnaires de contexte. Cette approche suit le patron de conception classique try... except... finally et permet d'encapsuler l'exécution d'un bloc de code de manière propre et sécurisée.

Un gestionnaire de contexte (ou context manager) gère donc l'acquisition et la libération de ressources dans un bloc de code. Il a pour but de garantir que les ressources sont correctement initialisées et nettoyées, même en cas d'erreur ou d'exception.

Un context manager doit implémenter deux méthodes spéciales : __enter__() et __exit__(). La méthode __enter__() est appelée au début du bloc with et est responsable de l'initialisation des ressources. Elle renvoie généralement l'objet qui sera associé au gestionnaire de contexte. La méthode __exit__() est appelée à la fin du bloc with et est responsable de la libération des ressources.

Avec ce premier gestionnaire basique, on voit clairement l'ordre d'exécution.

class MyContextManager:
    def __enter__(self):
        print("Initialisation des ressources")
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        print("Nettoyage des ressources")

with MyContextManager() as cm:
    print("Exécution du bloc de code")
$ python contexte.py
Initialisation des ressources
Exécution du bloc de code
Nettoyage des ressources

Ainsi, on peut s'amuser à re-coder un context manager qui va lire ou écrire dans des fichiers. On utilse la fonction open() qui nous renvoie un flux de données[1]. Le flux est ouvert dans la méthode __enter__() et on n'oublie pas de le fermer dans la fonction __exit__().

class Fichier:
    def __init__(self, nom, mode):
        self.nom = nom
        self.mode = mode
    def __enter__(self):
        self.fichier = open(self.nom, self.mode)
        return self.fichier
    def __exit__(self, exc_type, exc_value, traceback):
        self.fichier.close()


with Fichier("sfeir.txt", 'w') as f:
    f.write("sfeir.dev")

La classe, non ?

Python fournit également le module contextlib qui propose un décorateur @contextmanager afin de créer des gestionnaires de contexte de manière plus concise, sans avoir à définir une classe complète. Si on remplace notre classe Fichier, ça donne ceci :

from contextlib import contextmanager

@contextmanager
def fichier(nom, mode):
    try:
        f = open(nom, mode)
        yield f
    finally:
        f.close()

with fichier("sfeir.txt", 'w') as f:
    f.write("sfeir.dev")

Avec le décorateur, on transforme la fonction fichier() en un gestionnaire de contexte. La fonction utilise le mot-clé yield pour indiquer le point d'entrée et de sortie du contexte. Autrement dit, on prépare les ressources nécessaires pour le contexte avant yield puis on les détruit après.

Dans notre code, on utilise yield pour retourner le flux qui sera utilisé à l'intérieur du bloc with. Une fois le bloc terminé, la partie finally est exécutée pour fermer le fichier.

Les context managers en action

On retrouve les gestionnaires de contexte aussi bien dans la librairie standard de Python que dans les modules populaires. Voici quelques exemples supplémentaires de gestionnaires de contexte dans différents domaines.

# Gestion d'une connexion vers une base données
import sqlite3

with sqlite3.connect("database.db") as conn:
    cursor = conn.cursor()
    cursor.execute("SELECT * FROM table")
    results = cursor.fetchall()
    print(results)



# Gestion d'une session dans le module requests
# Une session conserve les paramètres et les cookies entre plusieurs requêtes
import requests

with requests.Session() as session:
    # Effectuer des requêtes HTTP ici
    response = session.get("https://www.sfeir.dev")
    print(response.status_code)



# Gestion d'une connexion vers un Redis    
import redis

with redis.Redis(host='localhost', port=6379) as r:
    r.set('cle', 'valeur')
    valeur = r.get('cle')
    print(valeur)



# Gestion de threads concurrents
# Ici, 10 threads doivent incrémenter la même variable
# Sans le lock, les threads peuvent accéder simultanément à la variable partagée,
# ce qui peut entraîner des incohérences et des erreurs de valeur.
# Le lock permet d'assurer qu'un seul thread à la fois peut modifier la variable,
# garantissant ainsi la cohérence des données.
import threading

lock = threading.Lock()
counter = 0

def thread_function():
    global counter
    thread_name = threading.current_thread().name
    for _ in range(10):
        with lock:
            counter += 1
            print(f"{thread_name} incrémente, valeur du compteur : {counter}")

threads = [threading.Thread(target=thread_function, name=f"Thread {i}") for i in range(1, 11)]

for thread in threads:
    thread.start()

for thread in threads:
    thread.join()

Niveau supérieur

Pour aller plus loin, les gestionnaires peuvent se souvenir de l'état précédent et adapter leur comportement en fonction du contexte. Cela permet de créer des contextes imbriqués où certaines actions sont effectuées à l'entrée et à la sortie de chaque niveau de contexte. On parle alors de gestionnaire réentrant. Voici un exemple de ce type de context manager :

class Indentation:
    def __init__(self):
        self.niveau = 0
    
    def __enter__(self):
        self.niveau += 1
        return self
    
    def __exit__(self, exc_type, exc_val, traceback):
        self.niveau -= 1
    
    def imprimer(self, texte):
        print(f"{'    ' * self.niveau}{texte}")


with Indentation() as indent:
    indent.imprimer('Niveau 1')
    with indent:
        indent.imprimer('Niveau 2')
        with indent:
            indent.imprimer('Niveau 3')
    indent.imprimer('Encore niveau 1')
$ python contexte.py
    Niveau 1
        Niveau 2
            Niveau 3
    Encore niveau 1

Dans cet exemple, Indentation maintient un état niveau qui est incrémenté à chaque niveau de contexte imbriqué et décrémenté lors de la sortie de chaque niveau. Cela permet d'adapter le comportement du manager réentrant en fonction du niveau de contexte actuel. En sortie, on voit la structure qui reflète la profondeur du contexte grâce à l'indentation.

Pour les curieux

Je vous renvoie vers la documentation officielle du module contextlib. Cette page revient sur le décorateur que l'on a rapidement évoqué mais parle aussi des gestionnaires de contexte asynchrones, des gestionnaires réutilisables comme le Lock ou encore du gestionnaire ExitStack.


  1. Si vous avez bien suivi, open retourne un objet qui est un flux, mais qui est aussi un context manager qu'on peut utiliser avec with. Pour en savoir plus, voici les liens vers la documentation de la fonction open et de l'objet IOBase. ↩︎

Dernier