Constructions « with » en langage… bash ! – Partie 1/2

Dis-moi petit syntax checker, qu’as-tu à me dire sur mon code source ? Comment ça, tu me rends la main tout de suite ? Tu n’as rien trouvé ?? Pas le moindre petit warning ??? Allez, ne sois pas timide, je t’ai mis l’option ultra-verbose je te signale !! T’es pas censé être timide !! Écoute, tu vois bien que je travaille seul là-dessus… si tu ne me dis rien, qui d’autre va me faire la conversation ?? Bon. Tu l’auras voulu. Voyons si tu resteras de marbre quand j’aurai ajouté cette structure exotique à mon code source…

Quelques mois plus tôt…

En ce moment je travaille sur debootstick, un outil pour générer des systèmes bootables. Mon but final est de pouvoir enchaîner les commandes suivantes sur un système Debian (ou Ubuntu…) :

L’outil debootstrap (à ne pas confondre avec debootstick donc !) utilisé en première ligne est un grand classique des systèmes Debian. Il permet de générer dans le répertoire donné en paramètre (ici os_tree) une arborescence minimale d’OS Debian (ici en version jessie). Mon outil debootstick se chargera alors, dans un deuxième temps, de convertir cette arborescence en image bootable (usb.img dans cet exemple).

On pourra ensuite écrire l’image bootable obtenue sur une clé USB (ici /dev/sdb) :

Et l’OS pourra alors être démarré sur n’importe quelle machine.

Vous vous dites peut-être qu’on pourrait simplifier la chose, côté utilisateur, en intégrant dans debootstick l’appel préliminaire à la commande debootstrap. C’est d’ailleurs ce que font, en général, les outils de ce genre. De mon côté, j’ai préféré suivre la philosophie UNIX et restreindre l’outil à une seule fonction « atomique ». Cela rendra l’outil plus souple et, paradoxalement, plus puissant. En effet, on peut imaginer toute une gamme d’autres scénarios pour générer ou customiser notre arborescence os_tree. Par exemple :

Et voilà. Après transfert sur une clé USB, on peut faire booter sur une machine physique ce qui n’était jusqu’à présent qu’un conteneur virtuel. Sympa, non ?

Le souci, c’est que pour l’instant cet outil n’existe que dans mon imagination. J’en suis dans les premières phases de développement, et je n’avance pas bien vite. Ce que je vous ai présenté comme une simple « conversion » cache en réalité une certaine complexité : il faut créer un fichier image, le partitionner, demander au noyau de créer des devices virtuels pour manipuler ces partitions, initialiser les systèmes de fichiers, faire des mount et des chroot, copier l’arborescence, installer les éventuels paquets manquants (noyau linux, bootloader, etc.), installer les bootloaders (BIOS et UEFI), puis tout redéfaire. Et je simplifie pas mal de choses (par exemple, pour obtenir une image de taille minimale, il faut ruser). Le plus ennuyeux, c’est quand on rencontre une erreur au beau milieu de l’exécution. Le cas typique, c’est le « no space left on device ». Le script s’arrête, et il laisse votre machine dans un état imprévisible, avec des montages sur un fichier temporaire qui n’existe plus, etc. Si on essaie de faire le ménage à la main, on y passe un bon quart d’heure. Jusque-là, j’optais donc pour la méthode Windows : je rebootais la machine. Mais je perds clairement trop de temps.

Pour résoudre ce souci, il faudrait que debootstick enregistre cet empilement de commandes délicates. En cas d’imprévu, il serait alors capable de tout défaire, en dépilant. S’il était écrit en Python par exemple, on pourrait utiliser des blocs with imbriqués. Mais debootstick est clairement orienté « système », donc le shell est plus approprié. Et en shell, pas de bloc with !

Pardon ? On me dit dans l’oreillette que vous ne seriez pas contre un petit rappel sur ce fameux bloc with. Faisons donc le point sur cette construction, telle qu’elle apparaît en Python.

1. Bloc « with » en Python

Prenons pour exemple la maxime : « Avec ce marteau, tous les problèmes ressemblent à des clous » :

On voit déjà que le bloc with met en évidence l’outil (ici le marteau), ainsi que le périmètre de son utilisation, le tout de manière assez élégante. Mais allons un peu plus loin. En fait, il y a du code caché qui s’exécute à l’entrée et à la sortie du bloc. Complétons notre exemple :

Voilà ce que ça donne à l’exécution :

Et voici quelques explications.

  1. Quand l’interpréteur Python a rencontré notre bloc with, il exécute prendre_marteau().
  2. S’agissant d’un bloc with, le résultat de prendre_marteau() doit être un objet qui implémente les méthodes spéciales __enter__() et __exit__(). On appelle cet objet un context manager. L’interpréteur appelle alors la méthode __enter__() du context manager.
  3. La méthode __enter__() renvoie un résultat, ici self (c’est souvent le cas), et ce résultat est assigné à la variable spécifiée après l’instruction as (ici marteau).
  4. L’interpréteur exécute le contenu du bloc with.
  5. En sortie du bloc, l’interpréteur appelle la méthode __exit__() du context manager.
  6. L’exécution reprend après le bloc with.

Et que se passe-t-il si on essaie de clouer autre chose que des clous ? Essayons :

Pas de problème pour clouer clou1, en revanche la même tentative sur vis a levé une exception. Cette exception a visiblement interrompu la suite du bloc (puisque clou2 n’a pas non plus été cloué), et a provoqué une sortie prématurée du programme (puisque le message « Au revoir! » n’apparaît pas).

En revanche, on voit bien le message « marteau rangé », ce qui prouve que la fonction __exit__() a bien été appelée, pour faire le ménage en sortie du bloc with. Et ceci malgré l’exception !!

Les connaisseurs me diront qu’on pourrait obtenir le même genre de comportement en utilisant la clause finally d’un bloc try. En fait, la sémantique est quand même un peu différente, car avec un bloc with, l’exception n’est pas capturée, elle continue donc son chemin en dehors du bloc. Souvent, c’est ce qu’on attend du programme : en cas d’imprévu, on préfère sortir au plus tôt, pour limiter les dégâts. D’autre part, dans un programme où on doit « faire » puis « défaire » quelque chose, les méthodes __enter__() et __exit__() proposent une sémantique parfaite. Meilleure à mon avis qu’un bloc try…finally.

Le langage python fournit en standard des objets faisant office de context manager. C’est le cas par exemple des « objets fichiers ». Ainsi, sans connaître le détail des méthodes __enter__() et __exit__(), on peut écrire :

Ici, l’avantage, c’est qu’on s’assure que le fichier sera fermé en sortie de bloc, quoi qu’il arrive.

Je ne vais pas m’étaler beaucoup plus, mais sachez qu’on peut très bien imbriquer plusieurs blocs with. L’idée, c’est qu’on va empiler des traitements via les fonctions __enter__() et les dépiler via les fonctions __exit__(). Et le dépilage interviendra donc dans tous les cas (exécution normale ou exception).

Vous comprenez donc pourquoi j’aimerais pouvoir appliquer le même principe à mon script shell debootstick. Donc essayons !

2. Bloc « with » en bash (??)

2.1 Implémentation naïve

On va commencer simplement et faire notre test à partir du code bash suivant, proche de la version Python :

À la fin du code, on voit ce qui ressemble à un bloc with, entre deux balises with et end_with. Afin qu’elles soit réutilisables, je les ai définies dans un fichier with_bloc.sh séparé, importé en deuxième ligne.

La deuxième chose à remarquer dans ce code, c’est la convention que j’ai adoptée : pour pouvoir utiliser une fonction <f>() dans un bloc with, il faudra au préalable définir la fonction defaire_<f>(). Comme vous vous en doutez, cette fonction sera appelée automatiquement au moment du end_with (avec les mêmes arguments).

Avant de vous dévoiler ce que j’ai mis dans with_bloc.sh, voyons déjà si ça marche :

Apparemment, oui, en tout cas dans ce cas simple.

Voici donc le contenu de with_bloc.sh :

Comme vous voyez, il n’y a pas des masses de code ; with et end_with sont en fait de simples fonctions. L’idée générale, c’est qu’à chaque appel with(), on exécute bien sûr la commande donnée en paramètre, mais on doit aussi stocker quelque part la commande defaire_<qqchose> <args> qui sera appelée au moment du end_with. Et pour que ça fonctionne quand on a plusieurs with imbriqués, on doit stocker ces commandes sur une pile : on empile à chaque appel with(), on dépile à chaque end_with().

Pour implémenter la pile [2], j’ai choisi de simplement chaîner des éléments dans une chaîne de caractères (variable a_depiler). Chaque élément est de la forme \n<cmd>. J’ai défini le séparateur \n via la variable EOL (lignes 1 et 2), parce qu’en bash si on écrit \n on écrit en réalité les 2 caractères backslash et n. En revanche, et c’est la raison pour laquelle j’ai choisi ce caractère, en matière de traitement ligne par ligne on est plutôt bien outillé, en shell. Ce codage permet ainsi, de manière assez évidente :

  • d’obtenir le dernier élément (= le haut de la pile) avec un simple tail -n 1 (ligne 12) ;
  • de dépiler ce dernier élément via head -n -1 (ligne 14).

Pour ce qui est d’empiler, dans la fonction with() donc, on a juste à utiliser la concaténation de chaîne (via l’opérateur +=, ligne 8). Vous noterez que sur cette ligne on ajoute le préfixe ‘defaire_’ à la commande originale, dans l’esprit de la convention décrite plus haut.

En dehors de cette gestion de pile, la ligne 7 permet d’exécuter la commande donnée en paramètre du with, et la ligne 13 permet d’exécuter la commande préfixée par ‘defaire_’.

2.2 Gestion des exceptions

Bon. Tout ça c’est bien beau, mais qu’est-ce qui se passe si on tente de clouer des vis ?

Mouais, c’est pas idéal… On est censé sortir du bloc with dès qu’une erreur survient, et là au contraire le programme a continué comme si de rien n’était. Il faudrait générer une exception quand on arrive sur la vis. Le problème c’est que… euh… en fait il n’y a pas de notion d’exception, en bash ! [3]

Bon. Mais à bien y réfléchir, on a quelque chose qui s’en rapproche. Un truc qu’on trouve dans tous les articles sur les « bonnes pratiques » en bash. C’est l’instruction set -e. Rajoutons-la en haut du script :

Relançons le même test :

Avouez qu’on a l’impression de sortir sur une exception ! En fait, quand on lance la commande set -e, on indique à l’interpréteur du script qu’il doit vérifier le code de retour de chaque commande. Et si ce code de retour est non nul (ce qui est un signe d’erreur), l’exécution est interrompue. Dans notre exemple, la commande return 1 de la fonction clouer() a donc suffi pour interrompre l’exécution.

Le souci dans ce cas, c’est que le marteau n’a pas été rangé. Cela implique que le code du end_with n’a pas été exécuté, contrairement à ce qui se passe avec un vrai bloc with, comme en Python.

Pour forcer l’appel du end_with en cas de sortie prématurée, il faudrait déclencher un traitement juste avant de sortir du script. Pour ce genre de choses, on utilise une fonctionnalité annexe de la commande trap. L’utilité principale de cette commande est d’associer un traitement à la réception d’un signal. Par exemple :

Etienne Dublé
[Ingénieur de Recherche CNRS au LIG]
Relu par H.-J. Audéoud

La seconde partie de cet article sera publiée prochainement sur le blog, restez connectés 😉

Retrouvez cet article (et bien d’autres) dans GNU/Linux Magazine n°204, disponible sur la boutique et sur la plateforme de lecture en ligne Connect !

Laisser un commentaire