Gestionnaires de contexte en Python

par Vincent Poulailleau - 12 minutes de lecture - 2456 mots

Pourquoi cette présentation ?

Le but des gestionnaires de contexte est d’écrire moins de code, de faire moins de tests, de faciliter la maintenance et l’évolutivité. Ils s’appliquent à un type d’actions récurrentes décrites ci-dessous.

Vous trouverez un exercice corrigé à la fin de cette présentation.

Théorie

Pourquoi les gestionnaires de contexte ?

Les context managers ou gestionnaires de contexte sont apparus dans Python 2.5 avec la PEP 343 il y a une quinzaine d’années. C’est une structure de code importante à connaître dès que vous faites des projets conséquents.

Nous avons, en programmant, régulièrement des séries d’actions du type :

  • phase initiale générique
  • phase intermédiaire différente à chaque fois
  • phase finale générique

Le but des context managers est de n’écrire qu’une seule fois le code de la phase initiale générique et de la phase finale générique. Moyennant une syntaxe particulière, nous pourrons ré-utiliser ces phases génériques autour de notre code intermédiaire spécifique.

Que pouvons-nous trouver comme exemple de phases génériques ?

Raymond Hettinger évoque la notion de sandwich : nous voulons toujours les mêmes tranches de pain en dessous et au-dessus, mais nous voulons pouvoir changer le contenu à l’intérieur.

Utiliser un gestionnaire de contexte

Vous avez certainement déjà utilisé un gestionnaire de contexte.

Certains sites internet vous disent que pour utiliser un fichier, il faut faire un code du style :

1
2
3
4
5
my_file = open("deleteme.txt")
do_some_operations()
my_file.write("J'ajoute du contenu dans le fichier.\n")
do_other_operations()
my_file.close()

C’est une mauvaise idée pour deux raisons.

La première ne nous concerne pas pour cet article, mais sachez que l’encodage utilisé pour ce fichier texte ne sera pas le même selon le système d’exploitation que vous utilisez.

Pour résoudre ce problème, il faut plutôt utiliser :

1
my_file = open("deleteme.txt", encoding="utf-8")

Une autre technique est d’attendre Python 3.10 et sa PEP 597, qui définit UTF-8 comme encodage par défaut.

La deuxième raison qui nous pousse à améliorer le code précédent est que si les autres opérations (do_some_operations() et do_other_operations()) plantent (lèvent une exception pour être exact), alors my_file.close() n’est jamais atteint et n’est donc pas exécuté. Le fichier ne sera alors pas fermé comme il se doit (dans la pratique, Python essaie de le fermer à un moment « judicieux »).

Vous pourriez donc écrire :

1
2
3
4
5
6
7
my_file = open("deleteme.txt")
try:
    do_some_operations()
    my_file.write("J'ajoute du contenu dans le fichier.\n")
    do_other_operations()
finally:
    my_file.close()

C’est une solution techniquement correcte, mais verbeuse. Elle va un peu à l’encontre de la PEP 20 qui nous dit que : « la lisibilité, ça compte ».

La solution est d’utiliser un context manager pour gérer l’ouverture et la fermeture du fichier. Pour activer ce gestionnaire de contexte, nous utilisons le mot clé with.

1
2
3
4
with open("deleteme.txt", encoding="utf-8") as my_file:
    do_some_operations()
    my_file.write("J'ajoute du contenu dans le fichier.\n")
    do_other_operations()

my_file.close() sera appelé en automatique par le gestionnaire de contexte, à la sortie du bloc with.

Autre exemple classique, vous pouvez, à partir d’un moment dans votre code, vouloir ignorer certaines exceptions, et plus tard ne plus les ignorer.

Ceci peut s’écrire :

1
2
3
4
5
try:
    do_some_operations()
    do_other_operations()
except ZeroDivisionError:
    pass  # ignore ZeroDivisionError

Et avec un context manager, cela donne :

1
2
3
4
5
from contextlib import suppress

with suppress(ZeroDivisionError):
    do_some_operations()
    do_other_operations()

Un dernier exemple pour finir, imaginez que vous avez une fonction qui passe son temps à appeler print. Vous pouvez vouloir ne pas afficher les résultats des print ou encore les rediriger vers un fichier. Vous pouvez le faire avec un gestionnaire de contexte.

1
2
3
4
5
from contextlib import redirect_stdout

with open("tous_les_prints.txt", "w", encoding="utf-8") as log:
    with redirect_stdout(log):
        function_with_many_print()

Vous avez pu noter que, dans certains cas, nous pouvons accéder au contexte créé par le gestionnaire de contexte. Cela se fait avec le mot clé as. Dans l’exemple précédent, le fichier ouvert est stocké dans une variable appelée log. Nous aurions donc pu faire un log.write("Joli titre du document\n").

Créer un gestionnaire de contexte

Un gestionnaire basique

Nous avons besoin, pour créer un gestionnaire de contexte, de deux fonctions :

  • une appelée au début
  • une appelée à la fin
  • éventuellement la possibilité de donner accès au contexte créé

Pour grouper ces fonctions, nous allons créer une classe. Afin que Python puisse s’y repérer, le nom des fonctions appelées au début et à la fin est standardisé dans la PEP 343 et documenté ici :

Crééons un exemple de gestionnaire de contexte pas très utile, mais montrant les bases :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class DummyCM:
    def __enter__(self):
        print("Ce n'est que le début")

    def __exit__(self, exc_type, exc_val, exc_tb):
        print("Il fallait bien que cela se termine\n")
        return True  # signale que nous avons géré les exceptions


with DummyCM():
    print("Coucou")

with DummyCM():
    print("Ceci est un autre message")
    print("Ceci est encore un autre message")

with DummyCM():
    print("Je vais faire n'importe quoi")
    x = 1 / 0  # division par 0, Python lève une ZeroDivisionError
    print("Oups !!!")

À l’exécution, nous aurons :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Ce n'est que le début
Coucou
Il fallait bien que cela se termine

Ce n'est que le début
Ceci est un autre message
Ceci est encore un autre message
Il fallait bien que cela se termine

Ce n'est que le début
Je vais faire n'importe quoi
Il fallait bien que cela se termine

Notez que dans le dernier exemple, le code 1 / 0 a planté (levé une exception ZeroDivisionError). Cela n’a pas empeché le gestionnaire de contexte d’afficher la phrase de conclusion. Par contre la fin du contenu du bloc with n’est pas exécutée.

Python a considéré que nous avions correctement géré l’exception car la méthode __exit__ de notre DummyCM a renvoyé True.

Remplaçons le return True par un return False :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class DummyCM:
    def __enter__(self):
        print("Ce n'est que le début")

    def __exit__(self, exc_type, exc_val, exc_tb):
        print("Il fallait bien que cela se termine\n")
        return False  # signale que nous n'avons pas géré les exceptions
        # En fait, il faut tout (y compris pas de return) sauf un return True


with DummyCM():
    print("Coucou")

with DummyCM():
    print("Ceci est un autre message")
    print("Ceci est encore un autre message")

with DummyCM():
    print("Je vais faire n'importe quoi")
    x = 1 / 0  # division par 0, Python lève une ZeroDivisionError
    print("Oups !!!")

Cela affiche au final :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
Ce n'est que le début
Coucou
Il fallait bien que cela se termine

Ce n'est que le début
Ceci est un autre message
Ceci est encore un autre message
Il fallait bien que cela se termine

Ce n'est que le début
Je vais faire n'importe quoi
Il fallait bien que cela se termine

Traceback (most recent call last):
  File "/tmp/cm.py", line 19, in <module>
    x = 1 / 0  # division par 0, Python lève une ZeroDivisionError
ZeroDivisionError: division by zero

Vous pouvez noter que, dans le dernier exemple, la phrase de conclusion (venant de __exit__) est bien affichée. Mais l’exception ZeroDivisionError n’est plus gérée par le gestionnaire de contexte, ce sera à l’utilisateur de s’en préoccuper.

Gestion générique des exceptions

Admettons que vous souhaitiez gérer un certain type d’exception de manière générique. Nous utiliserons dans ce cas, les trois arguments fournis à __exit__. Si une exception est survenue lors de l’exécution du corps de l’instruction with, les arguments de __exit__ contiennent le type de l’exception, sa valeur, et la trace de la pile (traceback). Sinon les trois arguments valent None.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class DummyCM:
    def __enter__(self):
        print("Ce n'est que le début")

    def __exit__(self, exc_type, exc_val, exc_tb):
        # print(exc_type, exc_val, exc_tb, sep="\n")
        # affichera par exemple :
        # <class 'ZeroDivisionError'>
        # division by zero
        # <traceback object at 0x7fc5b501bc00>

        print("Il fallait bien que cela se termine\n")

        # gérons de manière générique les divisions par 0
        if isinstance(exc_val, ZeroDivisionError):
            print("J'ai détecté une division par 0")
            print("T'inquiètes, je gère…\n\n")
            return True  # signale que nous avons géré les exceptions

        return False  # signale que nous n'avons pas géré les exceptions


with DummyCM():
    print("Je vais faire n'importe quoi")
    x = 1 / 0  # division par 0, Python lève une ZeroDivisionError
    print("Oups !!!")

with DummyCM():
    print("Je vais faire n'importe quoi")
    x = 1 / 0  # division par 0, Python lève une ZeroDivisionError
    a = [40, 80, 2]
    x = a[5]  # accède au 6ème élément de la liste n'en contenant que 3
    print("Oups !!!")

with DummyCM():
    print("Je vais faire n'importe quoi")
    a = [40, 80, 2]
    x = a[5]  # accède au 6ème élément de la liste n'en contenant que 3
    x = 1 / 0  # division par 0, Python lève une ZeroDivisionError
    print("Oups !!!")

Ce qui affiche à l’exécution :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Ce n'est que le début
Je vais faire n'importe quoi
Il fallait bien que cela se termine

J'ai détecté une division par 0
T'inquiètes, je gère…


Ce n'est que le début
Je vais faire n'importe quoi
Il fallait bien que cela se termine

J'ai détecté une division par 0
T'inquiètes, je gère…


Ce n'est que le début
Je vais faire n'importe quoi
Il fallait bien que cela se termine

Traceback (most recent call last):
  File "/tmp/cm.py", line 37, in <module>
    x = a[5]  # accède au 6ème élément de la liste n'en contenant que 3
IndexError: list index out of range

Nous voyons dans le premier exemple que le gestionnaire de contexte gère proprement la division par 0. Le reste du bloc with est ignoré, mais l’exception est attrapée proprement.

Le deuxième exemple est similaire, la seconde exception n’est pas levée car l’exécution du bloc with est arrêtée à la gestion de la première exception.

Dans le troisième exemple, l’accès à un mauvais élément de la liste a lève une exception de type IndexError. Celle-ci n’est pas gérée par le gestionnaire de contexte, car dans ce cas __exit__ renvoie False. Le reste du bloc with est ignoré, mais l’exception n’est pas attrapée proprement. C’est du ressort de l’utilisateur de s’en occuper.

Récupération du contexte

Avec le mot clé as, nous pouvons récuppérer le contexte créé par le gestionnaire de contexte, comme nous l’avions fait dans l’exemple suivant :

1
2
3
4
5
from contextlib import redirect_stdout

with open("tous_les_prints.txt", "w", encoding="utf-8") as log:
    with redirect_stdout(log):
        function_with_many_print()

Ce contexte est tout simplement la valeur renvoyée par __enter__.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class DummyCM:
    def __enter__(self):
        print("Ce n'est que le début")
        return "Un joli texte"

    def __exit__(self, exc_type, exc_val, exc_tb):
        print("Il fallait bien que cela se termine\n")
        return True  # signale que nous avons géré les exceptions


with DummyCM() as une_variable:
    print(f"Dans ce contexte, une_variable vaut « {une_variable} »")

À l’éxécution, cela affichera :

1
2
3
4
Ce n'est que le début
Dans ce contexte, une_variable vaut « Un joli texte »
Il fallait bien que cela se termine

Et avec un générateur ?

Vous êtes plus à l’aise avec les fonctions qu’avec les classes ? Vous savez ce qu’est un générateur ?

Je ne vais pas m’étendre sur le sujet, mais il est possible de convertir une fonction génératrice en gestionnaire de contexte avec contextlib.contextmanager.

Travaux pratiques

Énoncé

En utilisant time.process_time(), réalisez un gestionnaire de contexte qui mesure le temps d’exécution du contenu du bloc with et qui affiche ce temps avec un print.

Pour mesurer un temps, vous pouvez écrire ce genre de code :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import time

start = time.process_time()

# code dont on veut mesurer la performance
for x in range(1_000_000):
    y = x ** 2

end = time.process_time()

print(end - start, "secondes se sont écoulées")

Ce qui donne à l’exécution :

1
0.233183309 secondes se sont écoulées

Corrigé

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import time


class Chronometer:
    def __init__(self):
        self.start = None

    def __enter__(self):
        self.start = time.process_time()

    def __exit__(self, exc_type, exc_val, exc_tb):
        print(time.process_time() - self.start, "secondes se sont écoulées")


with Chronometer():
    # code dont on veut mesurer la performance
    for x in range(1_000_000):
        y = x ** 2

with Chronometer():
    # autre code dont on veut mesurer la performance
    for x in range(1_000_000):
        y = x ** 3

with Chronometer():
    # autre code dont on veut mesurer la performance
    for x in range(10_000_000):
        y = x ** 2

Résumé

Le but des gestionnaires de contexte est d’écrire moins de code, de faire moins de tests, de faciliter la maintenance et l’évolutivité.

Nous avons, en programmant, régulièrement des séries d’actions du type :

  • phase initiale générique
  • phase intermédiaire différente à chaque fois
  • phase finale générique

Le but des context managers est de n’écrire qu’une seule fois le code de la phase initiale générique et de la phase finale générique.

Exemples de phases génériques :

  • ouvrir / fermer
  • verrouiller / déverrouiller
  • modifier / réinitialiser
  • entrer / sortir
  • démarrer / arrêter

Les gestionnaires de contexte s’utilisent avec le mot clé with éventuellement suivi de as pour accéder au contexte nouvellement créé.

Un gestionnaire de contexte a deux méthodes :

  • __enter__ appelée au début du bloc with, peut renvoyer le contexte nouvellement créé
  • __exit__ appelée à la fin du bloc with, peut gérer les exceptions en fonction de la valeur de retour

Documents complémentaires

En anglais

Decorators and Context Managers
Keynote PyCon US 2013
Transforming Code into Beautiful, Idiomatic Python

En français

Conclusion

Enfin, n’hésitez pas à commenter à la fin de cette page (tout en bas). Dites si vous avez aimé ou non cet article. Signalez des informations complémentaires, des idées d’exercices. Et si un sujet ne semble pas clair, posez une question.