Gestionnaires de contexte

Avant de parler de cette spécificité du langage, je voudrais expliciter la notion de contexte. Un contexte est une portion de code cohérente, avec des garanties en entrée et en sortie.

Par exemple, pour la lecture d’un fichier, on garantit que celui-ci soit ouvert et accessible en écriture en entrée (donc à l’intérieur du contexte), et l’on garantit sa fermeture en sortie (à l’extérieur).

De multiples utilisations peuvent être faites des contextes, comme l’allocation et la libération de ressources (fichiers, verrous, etc.), ou encore des modifications temporaires sur l’environnement courant (répertoire de travail, redirection d’entrées/sorties).

Python met à notre disposition des gestionnaires de contexte, c’est-à-dire une structure de contrôle pour les mettre en place, à l’aide du mot-clef with.

with or without you

Un contexte est ainsi un scope particulier, avec des opérations exécutées en entrée et en sortie.

Un bloc d’instructions with se présente comme suit.

1
2
with expr as x: # avec expr étant un gestionnaire de contexte
    ... # operations sur x

La syntaxe est assez simple à appréhender, x permettra ici de contenir des données propres au contexte (x vaudra expr dans la plupart des cas). Si par exemple expr correspondait à une ressource, la libération de cette ressource (fermeture du fichier, déblocage du verrou, etc.) serait gérée pour nous en sortie du scope, dans tous les cas.

Il est aussi possible de gérer plusieurs contextes dans un même bloc :

1
2
with expr1 as x, expr2 as y:
    ... # traitements sur x et y

équivalent à

1
2
3
with expr1 as x:
    with expr2 as y:
        ... # traitements sur x et y

La fonction open

L’un des gestionnaires de contexte les plus connus est probablement le fichier, tel que retourné par la fonction open. Jusque là, vous avez pu l’utiliser de la manière suivante :

1
2
3
4
f = open('filename', 'w')
# traitement sur le fichier
...
f.close()

Mais sachez que ça n’est pas la meilleure façon de procéder. En effet, si une exception survient pendant le traitement, la méthode close ne sera par exemple jamais appelée, et les dernières données écrites pourraient être perdues.

Il est donc conseillé de plutôt procéder de la sorte, avec with :

1
2
3
with open('filename', 'w') as f:
    # traitement sur le fichier
    ...

Ici, la fermeture du fichier est implicite, nous verrons plus loin comment cela fonctionne en interne.

Nous pourrions reproduire un comportement similaire sans gestionnaire de contexte, mais le code serait un peu plus complexe.

1
2
3
4
5
6
try:
    f = open('filename', 'w')
    # traitement sur le fichier
    ...
finally:
    f.close()

Fonctionnement interne

Ça, c’est pour le cas d’utilisation, nous étudierons ici le fonctionnement interne.

Les gestionnaires de contexte sont en fait des objets disposant de deux méthodes spéciales : __enter__ et __exit__, qui seront respectivement appelées à l’entrée et à la sortie du bloc with.

Le retour de la méthode __enter__ sera attribué à la variable spécifiée derrière le as.

Le bloc with est donc un bloc d’instructions très simple, offrant juste un sucre syntaxique autour d’un try/except/finally.

__enter__ ne prend aucun paramètre, contrairement à __exit__ qui en prend 3 : exc_type, exc_value, et traceback. Ces paramètres interviennent quand une exception survient dans le bloc with, et correspondent au type de l’exception levée, à sa valeur, et à son traceback. Dans le cas où aucune exception n’est survenue pendant le traitement de la ressource, ces 3 paramètres valent None.

__exit__ retourne un booléen, intervenant dans la propagation des exceptions. En effet, si True est retourné, l’exception survenue dans le contexte sera attrapée.

Nous pouvons maintenant créer notre propre type de gestionnaire, contentons-nous pour le moment de quelque chose d’assez simple qui afficherait un message à l’entrée et à la sortie.

1
2
3
4
5
6
class MyContext:
    def __enter__(self):
        print('enter')
        return self
    def __exit__(self, exc_type, exc_value, traceback):
        print('exit')

Et à l’utilisation :

1
2
3
4
5
6
>>> with MyContext() as ctx:
...     print(ctx)
...
enter
<__main__.MyContext object at 0x7f23cc446cf8>
exit

Simplifions-nous la vie avec la contextlib

La contextlib est un module de la bibliothèque standard comportant divers outils ou gestionnaires de contexte bien utiles.

Par exemple, une classe, ContextDecorator, permet de transformer un gestionnaire de contexte en décorateur, et donc de pouvoir l’utiliser comme l’un ou comme l’autre. Cela peut s’avérer utile pour créer un module qui mesurerait le temps d’exécution d’un ensemble d’instructions : on peut vouloir s’en servir via with, ou via un décorateur autour de notre fonction à mesurer.

Cet outil s’utilise très facilement, il suffit que notre gestionnaire de contexte hérite de ContextDecorator.

1
2
3
4
5
6
7
8
from contextlib import ContextDecorator
import time

class spent_time(ContextDecorator):
    def __enter__(self):
        self.start = time.time()
    def __exit__(self, *_):
        print('Elapsed {:.3}s'.format(time.time() - self.start))

Et à l’utilisation :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
>>> with spent_time():
...   print('x')
...
x
Elapsed 0.000106s
>>> @spent_time()
... def func():
...     print('x')
...
>>> func()
x
Elapsed 0.000108s

Intéressons-nous maintenant à contextmanager. Il s’agit d’un décorateur capable de transformer une fonction génératrice en context manager. Cette fonction génératrice devra disposer d’un seul et unique yield. Tout ce qui est présent avant le yield sera exécuté en entrée, et ce qui se situe ensuite s’exécutera en sortie.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
>>> from contextlib import contextmanager
>>> @contextmanager
... def context():
...     print('enter')
...     yield
...     print('exit')
...
>>> with context():
...     print('during')
...
enter
during
exit

Attention tout de même, une exception levée dans le bloc d’instructions du with remonterait jusqu’au générateur, et empêcherait donc l’exécution du __exit__.

1
2
3
4
5
6
7
>>> with context():
...     raise Exception
...
enter
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
Exception

Il convient donc d’utiliser un try/finally si vous souhaitez vous assurer que la fin du générateur sera toujours exécutée.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
>>> @contextmanager
... def context():
...     try:
...         print('enter')
...         yield
...     finally:
...         print('exit')
...
>>> with context():
...     raise Exception
...
enter
exit
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
Exception

Enfin, le module contient divers gestionnaires de contexte, qui sont :

  • closing qui permet de fermer automatiquement un objet (par sa méthode close) ;
  • suppress afin de supprimer certaines exceptions survenues dans un contexte ;
  • redirect_stdout pour rediriger temporairement la sortie standard du programme.

Réutilisabilité et réentrance

Réutilisabilité

Nous avons vu que la syntaxe du bloc with était with expr as var. Dans les exemples précédents, nous avions toujours une expression expr à usage unique, qui était évaluée pour le with.

Mais un même gestionnaire de contexte pourrait être utilisé à plusieurs reprises si l’expression est chaque fois une même variable. En reprenant la classe MyContext définie plus tôt :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
>>> ctx = MyContext()
>>> with ctx:
...     pass
...
enter
exit
>>> with ctx:
...     pass
...
enter
exit

MyContext est un gestionnaire de contexte réutilisable : on peut utiliser ses instances à plusieurs reprises dans des blocs with successifs.

Mais les fichiers tels que retournés par open ne sont par exemple pas réutilisables : une fois sortis du bloc with, le fichier est fermé, il est donc impossible d’ouvrir un nouveau contexte.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
>>> f = open('filename', 'r')
>>> with f:
...     pass
...
>>> with f:
...     pass
...
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  ValueError: I/O operation on closed file.

Notre gestionnaire context créé grâce au décorateur contextmanager n’est pas non plus réutilisable : il dépend d’un générateur qui ne peut être itéré qu’une fois.

Réentrance

Un cas particulier de la réutilisabilité est celui de la réentrance. Un gestionnaire de contexte est réentrant quand il peut être utilisé dans des with imbriqués.

1
2
3
4
5
6
7
8
9
>>> ctx = MyContext()
>>> with ctx:
...     with ctx:
...         pass
...
enter
enter
exit
exit

On peut alors prendre l’exemple des classes Lock et RLock du module threading, qui servent à poser des verrous sur des ressources. Le premier est un gestionnaire réutilisable (seulement) et le second est réentrant.

Pour bien distinguer la différences entre les deux, je vous propose les codes suivant.

1
2
3
4
5
6
7
8
>>> from threading import Lock
>>> lock = Lock()
>>> with lock:
...     with lock:
...         pass
...

`

Python bloque à l’exécution de ces instructions. En effet, le bloc intérieur demande l’accès à une ressource (lock) déjà occupée par le bloc extérieur. Python met en pause l’exécution en attendant que la ressource se libère. Mais celle-ci ne se libérera qu’en sortie du bloc exétieur, qui attend la fin de l’exécution du bloc intérieur.

Les deux blocs s’attendent mutuellement, l’exécution ne se terminera donc jamais. On est ici dans un cas de blocage, appelé dead lock. Dans notre cas, nous pouvons sortir à l’aide d’un Ctrl+C ou en fermant l’interpréteur.

Passons à RLock maintenant.

1
2
3
4
5
6
>>> from threading import RLock
>>> lock = RLock()
>>> with lock:
...     with lock:
...         pass
...

Celui-ci supporte les with imbriqués, il est réentrant.

TP : Redirection de sortie (redirectstdout)

Nous allons ici mettre en place un gestionnaire de contexte équivalent à redirect_stdout pour rediriger la sortie standard vers un autre fichier. Il sera aussi utilisable en tant que décorateur pour rediriger la sortie standard de fonctions.

La redirection de sortie est une opération assez simple en Python. La sortie standard est identifiée par l’attribut/fichier stdout du module sys. Pour rediriger la sortie standard, il suffit alors de faire pointer sys.stdout vers un autre fichier.

Notre gestionnaire de contexte sera construit avec un fichier dans lequel rediriger la sortie. Nous enregistrerons donc ce fichier dans un attribut de l’objet.

À l’entrée du contexte, on gardera une trace de la sortie courante (sys.stdout) avant de la remplacer par notre cible. Et en sortie, il suffira de faire à nouveau pointer sys.stdout vers la précédente sortie standard, préalablement enregistrée.

Nous pouvons faire hériter notre classe de ContextDecorator afin de pouvoir l’utiliser comme décorateur.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import sys
from contextlib import ContextDecorator

class redirect_stdout(ContextDecorator):
    def __init__(self, file):
        self.file = file

    def __enter__(self):
        self.old_output = sys.stdout
        sys.stdout = self.file

    def __exit__(self, exc_type, exc_value, traceback):
        sys.stdout = self.old_output

Pour tester notre gestionnaire de contexte, nous allons nous appuyer sur les StringIO du module io. Il s’agit d’objets se comportant comme des fichiers, mais dont tout le contenu est stocké en mémoire, et accessible à l’aide d’une méthode getvalue.

1
2
3
4
5
6
7
8
9
>>> from io import StringIO
>>> output = StringIO()
>>> with redirect_stdout(output):
...     print('ceci est écrit dans output')
...
>>> print('ceci est écrit sur la console')
ceci est écrit sur la console
>>> output.getvalue()
'ceci est écrit dans output\n'
1
2
3
4
5
6
7
8
>>> output = StringIO()
>>> @redirect_stdout(output)
... def addition(a, b):
...     print('result =', a + b)
...
>>> addition(3, 5)
>>> output.getvalue()
'result = 8\n'

Notre gestionnaire de contexte se comporte comme nous le souhaitions, mais possède cependant une lacune : il n’est pas réentrant.

1
2
3
4
5
6
7
>>> output = StringIO()
>>> redir = redirect_stdout(output)
>>> with redir:
...     with redir:
...         print('ceci est écrit dans output')
...
>>> print('ceci est écrit sur la console')

Comme on le voit, ou plutôt comme on ne le voit pas, le dernier affichage n’est pas imprimé sur la console, mais toujours dans output. En effet, lors de la deuxième entrée dans redir, sys.stdout ne pointait plus vers la console mais déjà vers notre StringIO, et la trace sauvegardée (self.old_output) est alors perdue puisqu’assignée à sys.stdout.

Pour avoir un gestionnaire de contexte réentrant, il nous faudrait gérer une pile de fichiers de sortie. Ainsi, en entrée, la sortie actuelle serait ajoutée à la pile avant d’être remplacée par le fichier cible. Et en sortie, il suffirait de retirer le dernier élément de la pile et de l’assigner à sys.stdout.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import sys

class redirect_stdout(ContextDecorator):
    def __init__(self, file):
        self.file = file
        self.stack = []

    def __enter__(self):
        self.stack.append(sys.stdout)
        sys.stdout = self.file

    def __exit__(self, exc_type, exc_value, traceback):
        sys.stdout = self.stack.pop()

Vous pouvez constater en reprenant les tests précédent que cette version est parfaitement fonctionnelle (pensez juste à réinitialiser votre interpréteur suite aux tests qui ont définitivement redirigé sys.stdout vers une StringIO).


Ne changeons pas les bonnes habitudes, ces quelques pages de documentation vous régaleront autant que les précédentes.