Cursus
Motivation
Vous connaissez peut-être déjà l’instruction with, une manière concise de gérer des ressources en Python. Mais vous êtes-vous déjà demandé comment elle fonctionne en coulisse ? La puissance du mot-clé with vient des gestionnaires de contexte.
Les gestionnaires de contexte sont un motif de conception fondamental en Python qui offre une approche structurée de la gestion des ressources. Ils garantissent que les ressources sont acquises, utilisées correctement, puis libérées ou nettoyées, même en cas d’erreurs ou d’exceptions.
C’est particulièrement important lorsque vous manipulez des ressources comme des fichiers, des connexions réseau ou des connexions à une base de données. En utilisant des gestionnaires de contexte, nous pouvons écrire un code plus propre et plus fiable, sans craindre d’oublier de fermer un fichier ou de relâcher un verrou.
Dans ce tutoriel, nous irons au-delà des gestionnaires de contexte fournis par défaut en Python et apprendrons à écrire les nôtres.
Comprendre les gestionnaires de contexte en Python
Sous le capot, les gestionnaires de contexte sont des objets qui définissent deux méthodes spéciales : __enter__ et __exit__. La méthode __enter__ est appelée à l’entrée du bloc with, et sa valeur de retour est affectée à une variable dans ce bloc. La méthode __exit__, quant à elle, est appelée à la sortie du bloc with, qu’il se termine normalement ou sur une exception.
Cette structure garantit une bonne gestion des ressources. Voyons quelques gestionnaires de contexte intégrés à Python pour mieux comprendre :
Prenez l’exemple classique de l’ouverture d’un fichier :
with open('file.txt', 'r') as file:
data = file.read()
Ici, open('file.txt', 'r') agit comme gestionnaire de contexte. À l’entrée du bloc with, la méthode enter de l’objet fichier est appelée, ouvre le fichier et l’assigne à la variable file. Vous pouvez ensuite utiliser file.read() pour accéder à son contenu.
Ensuite, la méthode __exit__ de l’objet fichier est appelée à coup sûr lorsque le bloc with se termine, même en cas d’exception. Elle s’occupe de fermer le fichier, afin d’éviter de laisser des descripteurs ouverts.
2. Verrouillage de threads
Au-delà des fichiers, les gestionnaires de contexte servent aussi à la synchronisation des threads avec threading.Lock() :
import threading
lock = threading.Lock()
with lock:
# Section critique
print("This code is executed under lock protection.")
Ici, lock est un objet threading.Lock, donc un autre gestionnaire de contexte. À l’entrée du bloc with, __enter__ acquiert le verrou, garantissant qu’un seul thread exécute la section critique à la fois. Enfin, __exit__ libère le verrou à la sortie du bloc with, permettant aux autres threads de poursuivre.
3. Connexions aux bases de données
De même, les gestionnaires de contexte peuvent gérer des connexions à une base de données :
import sqlite3
with sqlite3.connect('database.db') as connection:
cursor = connection.cursor()
cursor.execute("SELECT * FROM table")
rows = cursor.fetchall()
L’appel sqlite3.connect('database.db') est un gestionnaire de contexte. Entrer dans le bloc with établit une connexion à la base et l’assigne à connection. Vous pouvez ensuite utiliser un cursor pour interagir avec la base. La méthode __exit__ garantit la fermeture de la connexion à la sortie du bloc with, évitant les fuites de ressources.
4. Sockets réseau
Les gestionnaires de contexte peuvent même gérer la communication réseau :
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect(('localhost', 8080))
s.sendall(b'Hello, world')
data = s.recv(1024)
Ici, socket.socket(socket.AF_INET, socket.SOCK_STREAM) crée un objet socket qui sert de gestionnaire de contexte. La méthode __enter__ crée la socket et, dans le bloc with, vous pouvez vous connecter, envoyer et recevoir des données. La méthode __exit__ veille à fermer correctement la socket une fois terminé.
5. Parcours de répertoires
Cette fois, os.scandir('.') offre un moyen d’itérer sur les entrées d’un répertoire :
with os.scandir('.') as entries:
for entry in entries:
print(entry.name)
os.scandir('.') agit ici comme gestionnaire de contexte. La méthode __enter__ ouvre une analyse du répertoire et vous pouvez itérer sur les entrées dans le bloc with. La méthode __exit__ nettoie l’analyse à la sortie.
Comme vous le voyez, l’instruction with, portée par les gestionnaires de contexte, simplifie la gestion des ressources en automatisant l’allocation et la libération.
Écrire des gestionnaires de contexte personnalisés en Python
Voyons maintenant comment écrire vos propres gestionnaires personnalisés pour un contrôle fin de la gestion des ressources.
Il existe deux grandes approches pour écrire des gestionnaires de contexte personnalisés : basée sur une classe et basée sur une fonction.
Approche basée sur une classe
L’approche par classe est la méthode la plus structurée et la plus flexible pour écrire des gestionnaires de contexte. Vous définissez une classe qui implémente les méthodes spéciales __enter__ et __exit__.
Voyons un exemple avec une classe Timer qui mesure le temps d’exécution :
import time
class Timer:
def __enter__(self):
self.start_time = time.time()
return self
def __exit__(self, exc_type, exc_value, traceback):
self.end_time = time.time()
elapsed_time = self.end_time - self.start_time
print(f"Elapsed time: {elapsed_time} seconds")
# Example usage
if __name__ == "__main__":
with Timer() as timer:
# Code block to measure the execution time
time.sleep(2) # Simulate some time-consuming operation
Elapsed time: 2.002082347869873 seconds
Ici, la classe Timer définit la méthode __enter__ pour capturer l’heure de début à l’entrée du bloc with. Elle retourne self pour permettre l’accès à l’objet dans le bloc. La méthode __exit__ calcule le temps écoulé à la sortie du bloc with et l’affiche.
Approche basée sur une fonction
Si vous préférez une approche plus concise, quitte à perdre un peu en flexibilité, l’approche fonctionnelle est une bonne option. Vous utilisez le décorateur contextmanager du module contextlib pour transformer une fonction en gestionnaire de contexte.
Voyons comment cela fonctionne :
import time
from contextlib import contextmanager
@contextmanager
def timer():
start_time = time.time()
yield
end_time = time.time()
elapsed_time = end_time - start_time
print(f"Elapsed time: {elapsed_time} seconds")
# Example usage
if __name__ == "__main__":
with timer():
time.sleep(2)
Elapsed time: 2.0020740032196045 seconds
Le décorateur @contextmanager transforme la fonction timer en gestionnaire de contexte. À l’intérieur, on capture start_time, puis l’instruction yield met l’exécution en pause pour laisser s’exécuter le code du bloc with.
Enfin, l’équivalent de __exit__ est assuré en capturant l’heure de fin et en affichant le temps écoulé.
En substance, vous écrivez la logique de __enter__ avant le mot-clé yield et celle de __exit__ après. Les deux approches aboutissent au même résultat ; le choix dépend de vos préférences en matière de structure et de lisibilité.
Exemples pratiques de gestionnaires de contexte
Maintenant que vous savez écrire des gestionnaires de contexte personnalisés en Python, examinons quelques cas d’usage où ils sont particulièrement utiles.
Gérer les opérations sur fichiers
Les opérations sur fichiers sont fréquentes dans de nombreuses applications. Qu’il s’agisse de lire ou d’écrire, une bonne gestion garantit l’intégrité des données et l’efficacité des ressources. Imaginons un scénario où nous voulons écrire du texte dans un fichier via un gestionnaire de contexte personnalisé.
class FileManager:
def __init__(self, filename, mode):
self.filename = filename
self.mode = mode
self.file = None
def __enter__(self):
self.file = open(self.filename, self.mode)
return self.file
def __exit__(self, exc_type, exc_value, traceback):
if self.file:
self.file.close()
# Example usage
if __name__ == "__main__":
with FileManager("example.txt", "w") as file:
file.write("Hello, world!\n")
Dans cet exemple, nous définissons une classe FileManager qui reprend le comportement de la fonction open de Python pour gérer les fichiers. Voyons le rôle de chaque méthode :
__init__(self, filename, mode): le constructeur de la classe. Il initialise l’objetFileManageravec le nom de fichier et le mode, qui précisent le fichier à ouvrir et le mode d’ouverture (lecture, écriture, ajout, etc.).__enter__(self): appelée à l’entrée du blocwith. Elle ouvre le fichier indiqué parfilenamedans lemodeindiqué. L’objet fichier ouvert est affecté àself.fileet renvoyé pour être utilisé dans le blocwith.__exit__(self, exc_type, exc_value, traceback): appelée à la sortie du bloc with, qu’une exception soit survenue ou non. Elle s’assure que le fichier est correctement fermé viaself.file.close()siself.filen’est pasNone. Cela évite les fuites de ressources et garantit le nettoyage.
Si vous inspectez le contenu de example.txt après l’exécution du code ci-dessus, vous devriez voir le texte écrit :
$ cat example.txt
Hello, world!
L’avantage de la classe FileManager est bien sûr de pouvoir embarquer une logique sur mesure pour travailler avec les fichiers. Par exemple, vous pouvez la modifier pour consigner dans un fichier texte quels fichiers sont ouverts ou fermés.
import datetime
class FileManager:
def __init__(self, filename, mode, log_filename="file_log.txt"):
self.filename = filename
self.mode = mode
self.log_filename = log_filename
self.file = None
def log_action(self, action):
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with open(self.log_filename, "a") as log_file:
log_file.write(f"{timestamp} - {action}: {self.filename}\n")
def __enter__(self):
self.file = open(self.filename, self.mode)
self.log_action("Opened")
return self.file
def __exit__(self, exc_type, exc_value, traceback):
if self.file:
self.file.close()
self.log_action("Closed")
# Example Usage
with FileManager("example.txt", "r") as file:
content = file.read()
print(content)
Hello, world!
Dans cette version modifiée :
- le module
datetimeest importé pour travailler avec des horodatages ; - la méthode
log_actionest ajoutée pour consigner les actions sur le fichier ; - le paramètre
log_filenamepermet d’indiquer le nom du fichier journal, par défaut'file_log.txt'; - le nom du fichier et l’action (ouverture/fermeture) sont ajoutés au journal via
log_actiondans les méthodes__enter__et__exit__; - dans
log_action,datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")récupère l’horodatage courant au format"YYYY-MM-DD HH:MM:SS"; - l’horodatage est inclus dans le journal avec le nom de fichier et l’action.
À présent, si nous affichons le contenu de file_log.txt, nous devrions voir les journaux :
$ cat file_log.txt
2024-04-03 12:37:02 - Opened: example.txt
2024-04-03 12:37:02 - Closed: example.txt
Autre cas d’usage pratique : créer un gestionnaire de contexte personnalisé pour SQLite :
import sqlite3
class DatabaseConnection:
def __init__(self, database_name):
self.database_name = database_name
self.connection = None
def __enter__(self):
self.connection = sqlite3.connect(self.database_name)
return self.connection
def __exit__(self, exc_type, exc_value, traceback):
if self.connection:
self.connection.close()
Là encore, cela imite la fonction sqlite3.connect, mais on peut l’améliorer en ajoutant une logique de gestion de base plus avancée. Vérifions son fonctionnement avec quelques fonctions d’exemple :
# Example usage
def create_table():
with DatabaseConnection("example.db") as connection:
cursor = connection.cursor()
cursor.execute(
"""CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
username TEXT,
email TEXT)"""
)
def insert_data(username, email):
with DatabaseConnection("example.db") as connection:
cursor = connection.cursor()
cursor.execute(
"INSERT INTO users (username, email) VALUES (?, ?)", (username, email)
)
connection.commit()
def fetch_data():
with DatabaseConnection("example.db") as connection:
cursor = connection.cursor()
cursor.execute("SELECT * FROM users")
return cursor.fetchall()
if __name__ == "__main__":
create_table()
insert_data("john_doe", "john@example.com")
insert_data("jane_doe", "jane@example.com")
users = fetch_data()
print("Users in the database:")
for user in users:
print(user)
Users in the database:
(1, 'john_doe', 'john@example.com')
(2, 'jane_doe', 'jane@example.com')
(3, 'john_doe', 'john@example.com')
(4, 'jane_doe', 'jane@example.com')
En découpant les opérations sur la base en fonctions et en utilisant le gestionnaire personnalisé que nous venons de créer, nous obtenons un code bien plus facile à maintenir.
Sujets avancés liés aux gestionnaires de contexte en Python
Dans cette section, nous aborderons des sujets avancés liés aux gestionnaires de contexte en Python, notamment la gestion des erreurs et l’imbrication de gestionnaires de contexte.
Gestion des erreurs
La gestion des erreurs est essentielle en programmation, et les gestionnaires de contexte en Python offrent un moyen pratique de gérer les erreurs au sein des opérations de gestion de ressources.
Dans l’exemple de la classe FileManager, la gestion des erreurs peut être implémentée dans la méthode __exit__ pour garantir le nettoyage même si une exception survient. Par exemple, si une erreur se produit lors de l’écriture dans le fichier (à l’intérieur du bloc with), nous voulons tout de même nous assurer que le fichier est fermé pour éviter les fuites de ressources. Nous pouvons modifier __exit__ comme suit :
def __exit__(self, exc_type, exc_value, traceback):
if self.file:
self.file.close()
if exc_type is not None:
print(f"An error occurred: {exc_value}")
Ici, nous fermons d’abord le fichier avec self.file.close() pour garantir le nettoyage. Puis nous vérifions si une exception s’est produite (exc_type is not None). Le cas échéant, nous affichons un message d’erreur pour indiquer ce qui n’a pas fonctionné.
Ici :
exc_typeest la classe de l’exception, commeAttributeErrorouIndexError.exc_valueest l’instance de l’exception avec son message.tracebackest la pile complète de l’erreur.
Exécutons la classe FileManager avec un code volontairement erroné :
with FileManager("example.txt", "r") as file:
# Simulate a sample error
content = file.read()
print(content[150])
An error occurred: string index out of range
Pour mettre en place une logique personnalisée lors du traitement des exceptions dans vos gestionnaires, voici un flux type que vous pouvez suivre :
def __exit__(self, exc_type, exc_value, traceback):
# Perform cleanup
...
# Handle the exception
if isinstance(exc_value, AttributeError):
# Handle AttributeError here
print(f"Exception message: {exc_value}")
# Handle another exception
elif isinstance(exc_value, AnotherError):
...
Imbriquer des gestionnaires de contexte
L’imbrication de gestionnaires de contexte vous permet de gérer plusieurs ressources simultanément, chacune avec son propre gestionnaire, au sein d’une seule instruction with. C’est particulièrement utile pour des opérations complexes impliquant plusieurs ressources. Voyons comment les imbriquer dans nos exemples.
Imaginons un scénario où l’on souhaite ouvrir un fichier et effectuer des opérations en base dans le même contexte. Par exemple, insérer les lignes d’un fichier CSV dans une base. On peut y parvenir en imbriquant les gestionnaires FileManager et DatabaseConnection dans une unique instruction with :
if __name__ == "__main__":
with FileManager("example.txt", "w") as file, DatabaseConnection("example.db") as connection:
...
Dans cet exemple, les gestionnaires FileManager et DatabaseConnection sont imbriqués dans la même instruction with. Cela nous permet d’ouvrir le fichier et d’établir la connexion à la base simultanément. Les opérations d’écriture et de base de données s’effectuent ensuite dans ce contexte combiné.
Gardez toutefois en tête la lisibilité du code. Nous vous recommandons de ne pas dépasser deux contextes simultanés.
Conclusion
Les gestionnaires de contexte en Python offrent une approche structurée de la gestion des ressources via l’instruction with, garantissant une allocation et une libération appropriées.
Tout au long de ce tutoriel, nous avons vu les bases des gestionnaires de contexte, parcouru des exemples pratiques et abordé des sujets avancés comme la gestion des erreurs et l’imbrication. En maîtrisant l’écriture de gestionnaires de contexte personnalisés, vous écrirez un code plus propre et plus fiable, améliorerez la gestion des erreurs et optimiserez l’utilisation des ressources.
Le sujet des gestionnaires de contexte personnalisés s’inscrit dans la catégorie « Python avancé ». Voici quelques ressources pour aller plus loin :
Je suis créateur de contenu en science des données avec plus de 2 ans d’expérience et l’une des plus grandes audiences sur Medium. J’aime écrire des articles détaillés sur l’IA et le ML avec une pointe de sarcasme, histoire de les rendre un peu moins austères. J’ai publié plus de 130 articles et un cours DataCamp, avec un autre en préparation. Mes contenus ont été vus par plus de 5 millions de personnes, dont 20 000 sont devenues abonnées sur Medium et LinkedIn.
