xieme-art/content/articles/2024/bash_printf/index.md

618 lines
24 KiB
Markdown

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/