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
- La fonction open
- Fonctionnement interne
- Simplifions-nous la vie avec la contextlib
- Réutilisabilité et réentrance
- TP : Redirection de sortie (redirectstdout)
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éthodeclose
) ;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.
- Définition du terme gestionnaire de contexte : https://docs.python.org/3/glossary.html#term-context-manager
- Gestionnaires de contexte : https://docs.python.org/3/library/stdtypes.html#context-manager-types
- Blocs
with
: https://docs.python.org/3/reference/datamodel.html#with-statement-context-managers - Module
contextlib
: https://docs.python.org/3/library/contextlib.html - PEP liée au bloc
with
: https://www.python.org/dev/peps/pep-0343/