Title: Bash avancé: barre de progression Category: sysadmin Tags: bash, script, printf, substitution Date: 2024-06-20 0:04 Cover: assets/backgrounds/turris_ssh_cle.jpg Après [les messages]({filename}../../2022/bash_gerer_les_messages_avance/index.md) et [les signaux]({filename}../../2022/bash_les_pieges/index.md), voici enfin un nouvel article dans la série **Bash avancé**. Avec presque deux ans de retard, il serait temps me direz-vous! Mais mieux vaut tard que jamais non? Cette article me servira de prétexte pour utiliser massivement la commande interne `printf` et vous montrer quelques cas d'usages. Nous verrons aussi la *substitution de processus*, la *substitution de paramètre* et d'autres mécanismes offerts par *Bash*. ## Un peu de théorie Il faut d'abord définir ce que j'entends par *barre de progression*: un élément affiché dans notre terminal représentant **la progression** d'une tâche exécutée par un script. Voici un exemple: ``` Task 2/10 ████▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ 20% ``` Cette barre se compose donc: * d'une légende / titre; * de la barre de progression représentant graphiquement l'avancée de notre tâche; * de la progression en pourcentage. ### Découpage en segments Afin de préparer notre implémentation, découpons notre barre de chargement en segments. Ce découpage nous permettra de faciliter le passage au code. ![Définition des segments d'une barre de progression]({attach}./images/barre_progression.svg) Voici la légende: 1. segment d'informations pour la légende; 2. segment d'actions effectuées; 3. segment d'actions en attentes; 4. segment complémentaire qui peut être le pourcentage de progression, ou toute autre information annexe. ## Une commande comme base Pour concevoir notre barre de progression il nous faut analyser la sortie d'une commande. Pour notre tutoriel, je vous propose d'utiliser la commande `ping`. ``` ping -c4 aquilenet.fr PING aquilenet.fr (185.233.100.8) 56(84) bytes of data. 64 bytes from hestia.aquilenet.fr (185.233.100.8): icmp_seq=1 ttl=60 time=29.1 ms 64 bytes from hestia.aquilenet.fr (185.233.100.8): icmp_seq=2 ttl=60 time=56.3 ms 64 bytes from hestia.aquilenet.fr (185.233.100.8): icmp_seq=3 ttl=60 time=52.8 ms 64 bytes from hestia.aquilenet.fr (185.233.100.8): icmp_seq=4 ttl=60 time=29.1 ms --- aquilenet.fr ping statistics --- 4 packets transmitted, 4 received, 0% packet loss, time 3003ms rtt min/avg/max/mdev = 29.055/41.806/56.258/12.764 ms ``` Ci-dessus, la commande a été lancée en indiquant le nombre de requêtes à envoyer via le paramètre `-c4` et chacune de ces requêtes affiche le numéro d'ordre (`icmp_seq=`). Maintenant que nous savons où aller, il est temps de commencer à écrire notre script: ```bash {! content/articles/2024/bash_printf/files/script1_ping.sh !} ``` [Télécharger le script]({attach}./files/script1_ping.sh) Il se compose d'une fonction `command` qui nécessite deux paramètres : un hôte et un nombre d'itérations. Attardons-nous un instant sur deux points importants. * Dans la mesure du possible il faut utiliser des variables locales dans les fonctions et en lecture seule pour s'assurer que la variable ne sera pas modifiée. Exemple: `local -r mavariable` déclare la variable locale `mavariable` en lecture seule avec le paramètre `-r`. * La commande et ses arguments sont enregistrés dans un tableau. Avec cette méthode plus besoin de gérer avec le gobbing et l'interprétation des paramètres lors de son appel. Lors de son lancement, nous pouvons observer l'affichage des différentes requêtes: ```text ./bash script1_ping.sh PING aquilenet.fr (185.233.100.8) 56(84) bytes of data. 64 bytes from hestia.aquilenet.fr (185.233.100.8): icmp_seq=1 ttl=60 time=29.1 ms 64 bytes from hestia.aquilenet.fr (185.233.100.8): icmp_seq=2 ttl=60 time=56.3 ms # [...] ``` ## Capturer les messages de sortie Il faut maintenant capturer la sortie de la commande ping et en extraire l'information pertinente : **le numéro de séquence**. [Télécharger le script complet]({attach}./files/script2_getoutput.sh) ### Récupérer la sortie standard ... Il est tout à fait possible de rediriger la sortie de la fonction `command` vers une autre fonction qui traitera le flux de données. C'est exactement le même principe utilisé par l'enchaînement de commande avec un *pipe* comme dans `dmesg | grep 'failure'`. Plusieurs techniques existent pour faire ceci en *Bash*, ici nous allons utiliser la **substitution de processus**. ```bash {! content/articles/2024/bash_printf/files/script2_getoutput.sh!lines=13-21 } ``` Ici la sortie standard de notre fonction `command` est envoyée vers la fonction `parse_output`. Notez que cette substitution peut aussi se faire dans l'autre sens avec la notation `<(commande)`, la sortie standard de la commande substituée sera envoyée vers l'entrée de la commande à gauche comme par exemple: ```bash # Affiche les éléments trouvés par la commande `ls` en les préfixants de # 'éléments: ', la sortie de notre substitution (ls) sera traitée par la boucle while read -r line; do printf "élément: %s\n" "$line" done < <(ls) ``` Sous le capot, le flux entre la sortie standard de `command` et l'entrée de `parse_output` se fait grâce à un descripteur de fichier. ### ... et la traiter Pour traiter le flux, `parse_output` place chaque ligne qui arrive dans la variable `$line` grâce à la bocle `while` couplée à la commande `read`. Ensuite `$line` est affichée en y ajoutant `out:`. Voici ce que donne l'exécution de ce script: ```text bash script2_getoutput.sh out: PING aquilenet.fr (2a0c:e300::8) 56 data bytes out: 64 bytes from hestia.aquilenet.fr (2a0c:e300::8): icmp_seq=1 ttl=55 time=20.0 ms out: 64 bytes from hestia.aquilenet.fr (2a0c:e300::8): icmp_seq=2 ttl=55 time=19.8 ms [...] ``` ## Récupérer le numéro de séquence Maintenant que le flux arrive dans `parse_output`, essayons de récupérer le numéro de séquence. Nous pourrons alors déterminer l'état d'avancement de notre commande. [Télécharger le script complet]({attach}./files/script3_rematch.sh) Pour l'extraire du reste de la ligne, utilisons l'opérateur de comparaison `=~` qui permet de vérifier la présence d'un motif dans une chaîne de caractères via **une expression rationnelle** comme par exemple: ```bash if [[ "$nom" =~ ^(Y|y)orick$ ]]; then printf "oui c'est moi!\n" fi ``` Il est possible de récupérer le motif capturé entre parenthèses ayant "matché" grâce à la variable `$BASH_REMATCH`, utilisée dans `parse_output`: ```bash {! content/articles/2024/bash_printf/files/script3_rematch.sh!lines=13-22 } ``` En plus du numéro de séquence, le temps est aussi capturé afin d'illustrer le fonctionnement de la variable `$BASH_REMATCH` qui est en fait **un tableau**: * l'indice `0` permet de récupérer entièrement la chaîne qui correspond au motif; * l'indice `1` au premier élément capturé, ici le numéro de séquence qui se trouve après `icmp_seq=` * et l'indice `2` à tout ce qui se trouve après `time=`. Voici ce que donne l'exécution de ce script: ```text bash script3_rematch.sh séquence: 2 sur 16 temps:20.2 ms séquence: 3 sur 16 temps:20.5 ms séquence: 4 sur 16 temps:19.7 ms [...] ``` Nous avons maintenant tout les éléments utiles pour la construction de notre barre de progression. ## Première barre de chargement Il faut commencer doucement, par une barre de chargement aussi simple que possible, sans fioriture. Nous allons ajouté la fonction `draw_progressbar` qui se charge du dessin à l'écran. [Télécharger le script complet]({attach}./files/script4_progress.sh) ```bash {! content/articles/2024/bash_printf/files/script4_progress.sh!lines=14-26 } ``` `draw_progressbar` attend deux paramètres: le nombre total d'éléments et celui en cours. Cette fonction est appelée depuis `parse_output` et elle utilise `printf` pour dessiner les deux segments nécessaires (mais pas que...). ### `printf` vers une variable Ne nous attardons pas pas sur les deux premières variables locales de notre fonction `draw_progressbar`. Les deux suivantes -- `progress_segment` et `todo_segment` sont par contre plus intéressantes. ```bash {! content/articles/2024/bash_printf/files/script4_progress.sh!lines=42 17-21 42 } ``` `printf -v progress_segment` permet **d'affecter le résultat de la commande `printf`** à la variable locale `progress_segment` au lieu de l'afficher sur la sortie standard. #### `printf`: format, arguments, spécification Le premier argument d'une commande `printf` est le *format*, les suivants sont les *paramètres*. Les arguments sont affichés par le format par l'intermédiaire de *spécifications*. ```bash number=2 drink="water" printf "I have %d bottle of %s.\n" "$number" "$drink" ``` Dans l'exemple suivant: * `"I have %d bottle of %s.\n"` est le format, * `$number` et `$drink` sont les arguments * `%d` et `%s` sont des spécifications: * `%d` indique que le premier argument doit être affiché comme un nombre entier; * `%s` indique que le second argument doit être affiché comme une chaîne de caractère. Il est possible de définit la taille **minimale d'une spécification**, les caractères manquants seront remplacés par des espaces, comme par exemple: ```text printf "bonjour %10s, bienvenue\n" "monsieur" "madame" "Linus Torvalds" bonjour monsieur, bienvenue bonjour madame, bienvenue bonjour Linus Torvalds, bienvenue ``` Vous remarquerez que la taille de 10 caractères a bien été réservée pour les deux premiers éléments. Le dernier par contre contient plus de 10 caractères: il dépasse de cet espace. Vous remarquez aussi une caractéristique particulière de `printf`. Une seule spécification est utilisée pour trois arguments, le format est donc répété trois fois. #### Et dans notre script... ```bash {! content/articles/2024/bash_printf/files/script4_progress.sh!lines=42 20 21 } ``` Dans le script la taille des deux segments est définie: * par **la variable** `progress` pour le *segment des actions effectuées* * par le résultat d'une soustraction pour le *segment des actions en attente*. *Bash* réalise les expansions avant de passer dans la commande `printf`, pratique n'est ce pas! Pour ces deux `printf` le formats a une spécification avec une taille minimale égale à la taille du segment que nous voulons obtenir (car les arguments sont vides). Nous obtenons **deux variables qui contiennent un nombre d'espace égal à la taille des segments représentés**, il ne reste plus qu'à assembler. ### On passe au dressage La commande `printf` suivante sert à afficher effectivement la barre de progression. D'abord elle est redirigée sur `stderr`, cette sortie est utilisée pour les erreurs, mais aussi pour afficher les informations de diagnostic. ```bash {! content/articles/2024/bash_printf/files/script4_progress.sh!lines=42 22-24 } ``` Son format contient deux spécifications de type chaîne de caractère et **un retour chariot**. Ce dernier permet au curseur de revenir au début de la ligne une fois dessinée. Lors du prochain passage dans la fonction `draw_progressbar`: la barre sera alors réécrite, **pas besoin de gérer l'effacement de la ligne**. Pour les deux arguments, nous utilisons *l'expansion de paramètre* afin de remplacer les espaces par les caractères choisis pour symboliser les deux segments de notre barre. Comment fonctionne le remplacement de motif avec *l'expansion de paramètre*? Voici deux exemples: ```bash variable="voici une plante, jolie plante non?" # remplacer plante par fleur dans `variable` # remplace la première occurrence trouvée echo "${variable/plante/fleur}" # affiche "voici une fleur, joli plante non?" #remplace toutes les occurrences echo "${variable//plante/fleur}" # affiche "voici une fleur, joli fleur non?" ``` Les caractères choisis pour remplacer les espaces sont affecté au tableau `PROGRESS_BAR_CHAR` initialisé en tout début de script. ```bash {! content/articles/2024/bash_printf/files/script4_progress.sh!lines=4 } ``` C'est la fonction `parse_output` qui fait appel à `draw_progressbar` ```bash {! content/articles/2024/bash_printf/files/script4_progress.sh!lines=28-34 } ``` ### Exécution Maintenant que vous avez compris comment le script est construit, il est temps de passer à l'exécution: ``` bash script4_progress.sh █████▒▒▒▒▒▒▒▒▒▒▒ ``` Notre barre de progression fonctionne, mais elle ne prend qu'une petite part de l'écran (les 16 caractères qui correspondent aux nombres de requêtes totales). Mais surtout, **aucune information n'est affichée**. ## Ajouter des informations Elle est bien belle notre barre de progression, mais là on ne sais pas du tout ce que fait le script. L'angle d'attaque: modifier les fonctions `parse_output` et `draw_progressbar` pour ajouter des informations sur la sortie standard. [Télécharger le script complet]({attach}./files/script5_informations.sh) ### La fonction `parse_output` Elle contient 2 conditions de plus qui vérifient la ligne en cours envoyée par `command`. ```bash {!content/articles/2024/bash_printf/files/script5_informations.sh!lines=30-46} ``` #### Afficher des information en introduction ... La première condition ajoutée récupère, dans la sortie de la commande `ping`, la ligne qui commence par `PING` comme dans l'exemple ci-dessous: ``` PING aquilenet.fr (2a0c:e300::8) 56 data bytes ``` De cette ligne est récupérée **le nom d'hôte** et **l'adresse IP associée** afin de l'afficher avant notre barre de progression. ```bash {!content/articles/2024/bash_printf/files/script5_informations.sh!lines=37} ``` L'information utile est capturée et réutilisée via la variable `BASH_REMATCH`. #### ... une conclusion ... La seconde condition ajoutée permet de récupérer la ligne qui contient le récapitulatif de ce qui a été fait par commande `ping` et de la renvoyer tel quelle sur la sortie standard. #### ... et ajouter le segment d'information Enfin un paramètre de plus est passé à la fonction `draw_progressbar`: une chaîne de caractère pour le *segment d'information* contenant la dernière mesure de temps retournée par `ping` (histoire d'utiliser `${BASH_REMATCH[2]}`) ```bash {!content/articles/2024/bash_printf/files/script5_informations.sh!lines=32-36} ``` ### la fonction `draw_progress` Le troisième paramètre passé à cette fonction est affecté à la variable `info_segment`. Son contenu est ajouté à l'affichage avant la barre de chargement. ```bash {!content/articles/2024/bash_printf/files/script5_informations.sh!lines=14-28} ``` ### lancement de notre script Notre barre de progression est toujours là mais avec un peu de contexte. ```text bash script5_informations.sh Launch ping command to aquilenet.fr (2a0c:e300::8) with 16 iterations Ping in progress (time: 20.7 ms) ███████▒▒▒▒▒▒▒▒▒ ``` Et une fois terminé la barre de progression disparait et laisse place au récapitulatif: ```text bash script5_informations.sh Launch ping command to aquilenet.fr (2a0c:e300::8) with 16 iterations 16 packets transmitted, 16 received, 0% packet loss, time 15019ms ``` ## Responsive design inside Maintenant que l'affichage des informations est en place, attardons nous sur le côté *responsive design* en adaptant la taille de notre barre à la largeur de l'écran.[Télécharger le script complet]({attach}./files/script2_getoutput.sh) La première question est bien évidement comment obtenir la largeur de la fenêtre de terminal? Lorque *Bash* est lancé en **mode interactif** cette largeur -- exprimée en nombre de colonnes [^colonne] -- est disponible via la variable `$COLUMNS`. Mais dans le cadre d'un script, cette variable n'est pas disponible si vous n'utilisez pas *Bash* comme interpréteur de commande. Il est possible d'utiliser la command interne `shopt` pour activer l'option `checkwinsize` mais elle pose deux problèmes: 1. Je n'ai pas réussi à activer l'option avec la commande `shopt -s checkwinsize` dans mes tests (mais un simple `shopt` rend les variables `COLUMNS` et `LINES` disponible); 2. la récupération des ces deux variables ne se fait **qu'après exécution d'une commande externe**, or il y en a peu dans notre script (`ping`), ce qui posera problème un peu plus loin dans cet article. [^colonne]: une colonne correspond à la largeur d'un caractère la commande `tput`, qui permet de récupérer des informations sur le terminal, fait très bien le job comme vous pouvez le voir dans ce petit bout de code ajouté dans la fonction `main()`: ```bash {!content/articles/2024/bash_printf/files/script6_responsive.sh!lines=54-57} ``` ### le dessin de la barre de progression Maintenant que nous avons le nombre de colonnes, passons au dessin de notre barre de progression. Voici la nouvelle version de `draw_progressbar`: ```bash {!content/articles/2024/bash_printf/files/script6_responsive.sh!lines=14-34} ``` Afin de déterminer la place disponible pour la barre de chargement (`$bar_size`) -- qui sera la partie responsive de notre ligne -- il faut soustaire la taille de notre segment d'information (`$info_segment`) à la largeur (`COLUMNS`). La progression doit maintenant s'exprimer par le ratio (`$progress_ratio`) entre les éléments traités (`progress`) et le nombre total d'éléments (`total`). L'expansion arithmétique de ***Bash* ne prend pas en charge les nombres flottants**, alors ce ratio s'exprime en pourcentage qui sera arrondi à l'entier le plus proche. Il suffit ensuite de calculer le nombre de colonnes occupées par le *segment d'actions effectuées* (`$progress_segment_size`) avec les deux valeurs obtenues précédemment (`$bar_size` et `$progress_ratio`). Pour obtenir le nombre de colonnes occupées par le *segment d'actions en attentes*. il suffit de soustraire la taille du segment d'actions effectuées (`$progress_segment_size`) à la taille de la barre (`$bar_size`). La suite est simple, dans les deux instructions `printf -v` qui permettent d'affecter le bon nombre d'espaces, il suffit d'utiliser `progress_segment_size` et `todo_segment_size` et le tour est joué. ### Exécution du script Il est temps de passer à l'exécution de cette version de notre script: ``` bash script6_responsive.sh Launch ping command to aquilenet.fr (2a0c:e300::8) with 16 iterations Ping in progress (time: 23.1 ms) █████████████████████████████▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ``` La ligne prend maintenant **toute la place disponible**, la barre de progression ne fait plus juste 16 colonnes. Mais un problème subsiste sur cette version. Changer la taille de la fenêtre de terminal ne change pas la taille de la barre. Si agrandir la fenêtre ne pose pas trop de problèmes -- la barre ne prend pas toute la place disponible -- la réduction de la taille mène à ceci: ![Capture d'écran montrant l'affichage de la barre de progression cassé lors de la réduction de la taille de la fenêtre]({attach}./images/responsive_corruped.png) L'explication est simple: la valeur de `$COLUMNS` n'est pas rafraichie! Il serait tout à fait possible de récupérer le nombre de colonnes au début de la fonction `draw_progress`, mais ce ne serait pas très optimisé pour une fonctionnalité utilisée rarement. **Une meilleure solution existe...** (mais elle n'est pas si simple). ## SIGWINCH à la rescousse Figurez-vous qu'un signal existe pour signifier un changement de taille d'une fenêtre de terminal. Mais l'implémentation dans notre script est plus épineuse qu'il n'y parait et nécessite quelques changements sur des fonctions d'affichage **pour que notre code soit compatible avec le plus d'émulateurs de terminal** possibles. [Télécharger le script complet]({attach}./files/script7_sigwinch.sh) D'abord ajoutons la fonction `change_column_size` qui se charge d'appliquer le changement du nombre de colonnes de la fenêtre de terminal. ```bash {!content/articles/2024/bash_printf/files/script7_sigwinch.sh!lines=56-61} ``` Cette fonction se charge: 1. de remplir la ligne courante sur `stderr` d'espace pour l'effacer; 2. ensuite d'effacer les caractères de la ligne entière grâce à la séquence `\033[0K\r` passée à `printf`; 3. enfin de récupérer le nouveau nombre de colonnes. Elle sera exécutée dès le changement de géométrie de la fenêtre de terminal (c'est ici que `SIGWINCH` intervient). Cependant une petite subtilité se cache dans la gestion des signaux en *Bash*: ils ne sont pas transmis au *sub-shell* et notre fonction `parse_output` est justement exécuté comme tel via la substitution de processus. Pour notre la capture de notre signal `SIGWICH`, il est alors préférable d'ajouter la commande `trap` au début de notre fonction `parse_output`. Si vous voulez plus d'information sir les signaux en Bash, je vous coneille la lecture de mon [précédent article]({filename}../../2022/bash_les_pieges/index.md). ```bash {!content/articles/2024/bash_printf/files/script7_sigwinch.sh!lines=37-40} ``` ### Exécution du script Notre barre de progression se redimensionne correctement, mais deux problèmes persistent. 1. Il faut attendre le rafraichissement de la barre de progression pour qu'elle prenne la bonne dimension une fois la fenêtre redimensionnée. 2. La diminution de la taille de la fenêtre de terminal crée des lignes "vides" ![Des lignes vides apparaissent lors de la diminution de la taille de la fenêtre]({attach}./images/responsive_blanklines.png) Un programme avec une véritable TUI [^TUI] utilise une boucle et des buffers pour gérer spécifiquement le rafraichissement de l'affichage. Dans le cadre d'un script et de cet article, nous garderons ces deux bugs mineurs et nous en resterons là. [^TUI]: Terminal User Interface -- interface utilisateur dans le terminal ## Bonus stage: un script plus aboutit Maintenant que tous nous avons une barre de progression fonctionnelle, il est temps de l'améliorer pour la rendre réutilisable et paramétrable -- et de mettre en place le segment manquant. [Télécharger le script complet]({attach}./files/script8_bonus.sh) D'abord il est possible d'ajouter des variables globales pour paramétrer les différents segments de la fonction `draw_progressbar`, ```bash {!content/articles/2024/bash_printf/files/script8_bonus.sh!lines=5-18} ``` Les variables finissant par `_TEMPLATE` servent de *templates* aux segments d'information et complémentaires. Elles contiennent des définitions de **formats** pour `printf` qui sont utilisée dans la fonction d'affichage comme par exemple la mise en gras du segment d'information. ```bash {!content/articles/2024/bash_printf/files/script8_bonus.sh!lines=28 2 40-49 2 59-63} ``` Dans `draw_progressbar`, les affichages du segment *d'information* et celui *complémentaire* sont conditionnels. Ensuite les formats des différentes commandes `printf` sont aussi donnés par les variables globales définies plus haut. Nous avons maintenant une fonction `draw_progressbar` et les variables associées nous permettant de personnaliser son apparence en fonction du contexte. ## en conclusion Au final, voici ce que que donne notre barre de progression: ![GIF animé de la barre de progression en fonctionnement]({attach}./images/video.gif) Nous avons abordé beaucoup de choses dans cet article un peu plus long que d'habitude. Principalement autour de la commande `printf` et ses nombreuses possibilités (mais nous n'avons pas tout vu...). J'espère vous avoir montré que par rapport à la commande `echo` habituellement utilisées dans les script, `printf` est réellement bien plus **puissante** et **utile**. Merci à [Heuzef][heuzef], Yishan et [RavenRamirez][alois] pour les nombreuse relectures, corrections et conseils. [heuzef]:https://heuzef.com/ [alois]:https://aloisbouny.fr/