# Writing better Bash scripts A series of 10 article(s), in reading order. # Bash - ASCII art > Apprenez à ajouter des bannières en ASCII art à vos scripts Bash pour un impact visuel plus fort. Avec un exemple de code complet. --- ![Bash - ASCII art](/img/v2/ascii_art.webp) **TL;DR** Ce court article montre comment ajouter une bannière en ASCII art à vos scripts Bash pour un peu de style visuel, générée avec l'outil gratuit patorjk.com/software/taag et affichée en haut du script. J'écris beaucoup de scripts Bash et j'aime garder la même approche pour chacun : une bannière, [une fonction de logging](/blog/bash-logging), [des blocs de documentation au-dessus de chaque fonction](/blog/linux-generate-documentation-from-bash-scripts). Une des choses que je fais toujours, c'est d'inclure une bonne vieille bannière en *ASCII Art*, peut-être pour le côté geek, mais surtout pour avoir un impact visuel plus fort. J'utilise pour créer mes bannières, alors regardons ça de plus près. *La même idée, appliquée à un site web plutôt qu'à une console : [Inject ASCII Art in any HTML pages rendered by Docusaurus](/blog/docusaurus-ascii-art).* ![Exemple d'ASCII art](./images/sample.webp) De mon côté, j'implémente la bannière comme ceci : **script.sh** ```bash #!/usr/bin/env bash set -euo pipefail function __main() { clear # http://patorjk.com/software/taag/#p=display&f=Big&t=My%20Nice%20Script cat <<\EOF __ __ _ _ _ _____ _ _ | \/ | | \ | (_) / ____| (_) | | | \ / |_ _ | \| |_ ___ ___ | (___ ___ _ __ _ _ __ | |_ | |\/| | | | | | . ` | |/ __/ _ \ \___ \ / __| '__| | '_ \| __| | | | | |_| | | |\ | | (_| __/ ____) | (__| | | | |_) | |_ |_| |_|\__, | |_| \_|_|\___\___| |_____/ \___|_| |_| .__/ \__| __/ | | | |___/ |_| EOF printf "%s\n\n" "(c) Copyright ..." # Program the functions of your script here, # such as calling your functions return 0 } __main "$@" ``` Et voici à quoi ça ressemble dans mon terminal bash : ![Terminal](./images/terminal.webp) --- # Bash - Charger des variables d'environnement depuis un fichier > Apprenez à charger les variables d'environnement d'un fichier .env dans un script Bash, afin d'externaliser la configuration et de réutiliser les variables dans d'autres applications comme Laravel. --- ![Bash - Charger des variables d'environnement depuis un fichier](/img/v2/bash.webp) **TL;DR** Cet article montre la bonne façon de charger les variables d'un fichier `.env` dans l'environnement d'un script Bash : `set -o allexport; source .env; set +o allexport`. Cette méthode gère correctement les valeurs contenant des espaces — contrairement au pattern `export $(... | xargs)`, très répandu mais peu fiable. Imaginez que vous ayez un fichier `.env` comme celui-ci et que vous souhaitiez le traiter dans un script Bash. **.env** ``` DOCKER_GIT_USEREMAIL="christophe@me.com" DOCKER_GIT_FULLNAME="Christophe Avonture" DOCKER_GIT_USERNAME="Me and myself" ``` Utiliser un fichier de configuration vous permet d'externaliser la gestion de vos constantes, mais aussi de réutiliser les variables d'une autre application, par exemple un site développé en Laravel. Voyons comment faire cela le plus proprement possible. Le snippet provient de Vous pouvez charger facilement ce fichier dans votre environnement avec les instructions suivantes : ```bash set -o allexport source .env set +o allexport ``` Une fois ceci fait, les variables seront accessibles comme n'importe quelle variable d'environnement dans votre script bash. À la sortie du script, les variables ajoutées sont supprimées (exactement comme dans un sous-shell). Utiliser source est la meilleure solution pour éviter les problèmes avec, par exemple, les espaces comme dans *Me and myself* : les autres solutions comme `export $(... | xargs)` donneront toujours des résultats imprévisibles. Pour l'illustrer, créez simplement un fichier appelé par exemple `test.sh` avec ce contenu : **test.sh** ```bash #!/usr/bin/env bash set -o allexport source .env set +o allexport echo "${DOCKER_GIT_FULLNAME} (${DOCKER_GIT_USEREMAIL})" ``` Avec le fichier `.env` fourni plus haut, nous obtiendrons cette sortie : ```bash $ ./test.sh Christophe Avonture (christophe@me.com) ``` C'est, à mon avis, la meilleure et aussi la plus simple façon d'utiliser un fichier externe pour stocker des éléments de configuration en Bash. Deux autres compagnons `.env` sur ce blog : [Compare environment files in the Linux console](/blog/compare-env-files-cli), pour repérer la clé que vous avez oublié de reporter depuis `.env.example`, et [Batch edit of environment file](/blog/update-env-files-cli), pour modifier la même variable dans une dizaine de projets d'un coup. --- # Bash - Script pour ajouter des fonctionnalités de log à votre script > Ajoutez des fonctionnalités de log robustes à vos scripts Bash grâce à cette petite bibliothèque réutilisable. Fichiers de log automatiques, horodatages et trace complète des appels de fonctions pour déboguer facilement. --- ![Bash - Script pour ajouter des fonctionnalités de log à votre script](/img/v2/bash.webp) **TL;DR** Cet article partage une bibliothèque Bash réutilisable, `log.sh`, qui ajoute le logging à n'importe quel script : vous la sourcez, vous appelez `log::write "message"` là où c'est utile, et les entrées horodatées sont écrites automatiquement dans un fichier de log. Chaque entrée contient une trace d'appel complète (quelle fonction, à quelle ligne, appelée depuis où) pour faciliter le débogage des scripts non interactifs ou lancés par cron. > Lisez aussi [Bash - Echo on the console and in a logfile in the same time](/blog/bash-console-log-together) Quand vous écrivez des scripts Bash, et surtout quand vous prévoyez de les lancer dans un cron, vous devriez mettre en place un fichier de log. Chaque action déclenchée par votre script devrait être enregistrée quelque part afin de pouvoir lancer le script en mode non interactif et, en cas de besoin, consulter le dernier fichier de log. *Le logging devient indispensable dès que votre script exécute [plusieurs jobs en parallèle](/blog/bash-parallel-task) : la sortie console de dix tâches concurrentes est illisible, un fichier de log horodaté ne l'est pas.* Voici un script que j'ai développé sous forme de bibliothèque, ce qui veut dire que vous pouvez facilement l'inclure dans votre code existant sans rien devoir changer. Il suffit d'inclure le fichier dans votre script (c'est-à-dire ajouter une ligne `source log.sh`) puis de prévoir ici et là un `log::write "Something to log"`. Facile, non ? Pour illustrer le fonctionnement du log, nous allons créer deux fichiers. Votre script (ici, je vais l'appeler `run.sh`) et celui qui contient l'implémentation du log (`log.sh`). ## Le résultat {#the-result} En lançant le script, on voit nos différents `echo`, mais aussi le contenu du log généré. ![Utilisation du log](./images/logging.webp) Vous pouvez bien sûr choisir de ne pas afficher le log automatiquement : il suffit de commenter la ligne `log::__displayLog`. ### Trace {#trace} Chaque instruction loguée sera accompagnée d'une trace, comme vous pouvez le voir. Par exemple, le bloc `[Function __main line 43]` signifie que l'appelant était la fonction `__main` et que c'était à la ligne `43`. ```log [2024-03-25T12:29:08+0100] Start ./run.sh [2024-03-25T12:29:08+0100] Running main with these parameters: --help [Function __main line 43] [2024-03-25T12:29:08+0100] Doing some stuff in test2 [Function test2 line 22;Function test1 line 30;Function __main line 48] [2024-03-25T12:29:08+0100] I'm doing something in the test5 function... [Function test5 line 4;Function test4 line 10;Function test3 line 16;Function test2 line 24;Function test1 line 30;Function __main line 48] [2024-03-25T12:29:08+0100] Logfile /tmp/run.sh.uplzaefiWq.log ended for ./run.sh. [2024-03-25T12:29:08+0100] Duration: 0 second(s) ``` Quand il y a plusieurs parents, vous obtenez la trace complète, par exemple `[Function test5 line 4;Function test4 line 10;Function test3 line 16;Function test2 line 24;Function test1 line 30;Function __main line 48]` : la fonction `__main` à la ligne 48 a appelé `test1`, qui a ensuite appelé `test2`, et ainsi de suite. Chaque instruction du log contiendra donc la trace complète, ce qui simplifie le débogage. ## Votre script {#your-script} Si vous avez déjà un script, ouvrez-le. Sinon, créez-en simplement un nouveau et copiez/collez le code ci-dessous. Nommez le fichier comme vous voulez (je vais l'appeler `run.sh` par exemple) et assurez-vous que le script est exécutable en lançant `chmod +x run.sh` dans votre console. **run.sh** ```bash #!/usr/bin/env bash set -o nounset -o errexit function test5() { log::write "I'm doing something in the test5 function..." return 0 } function test4() { test5 return 0 } function test3() { test4 return 0 } function test2() { log::write "Doing some stuff in test2" test3 return 0 } function test1() { test2 return 0 } function __init() { source ./log.sh return 0 } function __main() { __init log::write "Running main with these parameters: $*" echo "Welcome in this script" echo "Please do ..." test1 return 0 } __main "$@" ``` ## Le helper de log {#the-log-helper} Créez un fichier appelé `log.sh`, exactement dans le même dossier que votre script, et copiez/collez ce contenu : c'est lui qui produit la trace et le fichier de log montrés plus haut. **log.sh** ````bash #!/usr/bin/env bash # region - Intro block ----------------------------------------------- # # Working with log files # # ## How to use this script? # # 1. Include the script in your own Bash like this `source log.sh` (adjust path if needed). # 2. Here and there, where you want a log, just type `log::write "This is a log info"` # # ## Dependencies # # None # # endregion - Intro block -------------------------------------------- # let script exit if an unset variable is used or if a command fails set -o nounset set -o errexit # region - private function log::__main # # ## Description # # This function will initialize important global variables # This function is called as soon as this file is "sourced" (like "source log.sh") # to make sure variables are initialized as soon as possible. # # ## Examples # # ```bash # log::__main # ``` # # ## Exit code # # * 0 If successful. # # endregion function log::__main() { # "trap ... EXIT" allow to define a function to call when the script exits, # so, here, call our __logDestruct function trap log::__logDestruct EXIT # Example of a filename: /tmp/myScript.sh.1J6Sqb62.log # Set LOG_FILE="" if you don't want to write to a log file (but simpler to not source this file then) [[ ! -v LOG_FILE ]] && LOG_FILE="$(mktemp /tmp/"$(basename "$0")".XXXXXXXXXX.log)" # Quick function to export the system date in the chosen format. log::__logNow() { date +"%Y-%m-%dT%H:%M:%S%z"; } # Generate a trace dump like [test5,line 728;test4,line 731;test3,line 734;test2,line 737;test1,line 740;main,line 755] # i.e. the latest called function, then the one who called that function, ... until the very first function called # (the entry point function) log::__trace() { local traceInfo traceInfo="" LEN=${#BASH_LINENO[@]} # INDEX=0 is this __trace function, INDEX=1 is the log::write function so start at 2. for (( INDEX=2; INDEX < LEN-1; INDEX++ )); do traceInfo="${traceInfo}Function ${FUNCNAME[(( INDEX ))]} line ${BASH_LINENO[ (( INDEX - 1)) ]};" done traceInfo="$(echo "${traceInfo}" | sed 's/;$//g')" echo "[${traceInfo}]" return 0 } # From now, writing to 3 will write to the log file [[ -z "${LOG_FILE}" ]] || exec 3>"${LOG_FILE}" return 0 } # region - private function log::__logDestruct # # ## Description # # Before exiting the script, display or not the logfile content (and remove it then) # # ## Examples # # ```bash # log::__logDestruct # ``` # # ## Exit code # # * 0 If successful. # # endregion function log::__logDestruct() { # Only if the file isn't empty if [[ -s ${LOG_FILE} ]]; then logEntry="$(printf '[%s] Logfile %s ended for %s.' "$(log::__logNow)" "$LOG_FILE" "$0")" echo "${logEntry}" >&3 logEntry="$(printf '[%s] Duration: %d second(s)' "$(log::__logNow)" "$SECONDS")" echo "$logEntry" >&3 log::__displayLog fi return 0 } # region - public function log::write() # # ## Description # # Write a sentence to a logfile # # ## Examples # # ```bash # log::write 'This is a log entry' # ``` # # ## Parameters # # * @arg string The string to write to the logfile # # ## Exit code # # * 0 If successful. # * 1 An error has occurred. # # endregion function log::write() { # When LOG_FILE was not defined, it means no log so just exit [[ -z ${LOG_FILE} ]] && return 0 local -r message="${1:-}" [[ -z ${message} ]] && return 0 # No message so nothing to write in the log # Create the log file if not yet there [[ ! -f ${LOG_FILE} && ! "${LOG_FILE}" = "/dev/stdout" ]] && touch "${LOG_FILE}" # If the file isn't writable, error and return if [[ ! -w ${LOG_FILE} ]]; then errorMessage="ERROR - The ${LOG_FILE} file isn't writable. Exiting..." printf "\033[1;${__RED:-31}m%s\033[0m\n" "ERROR - ${message} Exiting..." >&2 # Write on stderr fi # If the file size is 0, echo a start info date/time; do it once if [[ ! -s ${LOG_FILE} ]]; then printf '[%s] %s\n' "$(date +"%Y-%m-%dT%H:%M:%S%z")" "Start $0" >&3 fi printf '[%s] %s %s\n' "$(date +"%Y-%m-%dT%H:%M:%S%z")" "${message}" "$(log::__trace)" >&3 return 0 } # region - private function log::__displayLog # # ## Description # # Display the content of the logfile # # ## Examples # # ```bash # log::__displayLog # ``` # # ## Exit code # # * 0 If successful. # # endregion function log::__displayLog() { # If the file is empty, nothing to do [[ ! -s ${LOG_FILE} ]] && return 0 printf "%s\n" "--------------------- LOG FILE CONTENT (${LOG_FILE}) ---------------------" # Show the log content but make sure to remove, if any, ANSI colors. # (@see https://stackoverflow.com/a/51141872/1065340) printf "\033[1;${__GRAY:-30}m%s\n" "" sed "s/\x1B\[[0-9;]\{1,\}[A-Za-z]//g" "${LOG_FILE}" printf "\033[0m\n\n" echo "" return 0 } # This script didn't contains executable code; only helpers. sourced=0 (return 0 2> /dev/null) && sourced=1 || sourced=0 if [ "${sourced}" -eq "0" ]; then printf "\033[1;31m%s\033[0m\n" "ERROR, the $0 script is meant to be sourced. Try 'source $0' and use public functions." >&2 # Write on stderr exit 1 fi log::__main ```` --- # Bash - Afficher sur la console et dans un fichier de log en même temps > Fatigué des scripts Bash silencieux ? Découvrez la méthode simple et efficace pour afficher la sortie d'une commande sur la console tout en l'écrivant ligne par ligne dans un fichier de log. --- ![Bash - Afficher sur la console et dans un fichier de log en même temps](/img/v2/bash.webp) **TL;DR** Cet article montre comment faire en sorte qu'une commande Bash de longue durée écrive à la fois sur la console et dans un fichier de log (au lieu de l'un ou l'autre seulement), en utilisant `eval` pour lancer la commande et en affichant chaque ligne à l'écran tout en l'ajoutant au fichier de log en temps réel — plutôt que de tout mettre en mémoire tampon pour l'afficher à la fin. Dans mon article précédent [Bash - Script to add logging features to your script](/blog/bash-logging), j'ai partagé une façon d'écrire des informations dans un fichier de log. En lançant `ls -alh /tmp`, vous obtenez la liste de tous les fichiers du dossier `/tmp` et elle s'affiche sur votre console. En lançant `ls -alh /tmp >> application.log`, vous ne verrez pas la liste dans votre console puisque tout sera écrit dans le fichier `application.log`. Comment afficher la sortie d'une commande comme `ls` par exemple à la fois sur la console et dans un fichier de log ? ## Résultat {#result} ```bash $ ./script.sh total 8.0K drwxr-xr-x 2 node node 4.0K Aug 9 18:04 . drwxr-xr-x 3 node node 4.0K Aug 9 18:04 .. -rw-r--r-- 1 node node 0 Aug 9 18:04 archive.zip -rw-r--r-- 1 node node 0 Aug 9 18:04 notes.txt -rw-r--r-- 1 node node 0 Aug 9 18:04 report.csv $ cat /tmp/test.log [2026-08-09T18:04:09+0000] total 8.0K [2026-08-09T18:04:09+0000] drwxr-xr-x 2 node node 4.0K Aug 9 18:04 . [2026-08-09T18:04:09+0000] drwxr-xr-x 3 node node 4.0K Aug 9 18:04 .. [2026-08-09T18:04:09+0000] -rw-r--r-- 1 node node 0 Aug 9 18:04 archive.zip [2026-08-09T18:04:09+0000] -rw-r--r-- 1 node node 0 Aug 9 18:04 notes.txt [2026-08-09T18:04:09+0000] -rw-r--r-- 1 node node 0 Aug 9 18:04 report.csv ``` Les mêmes lignes, deux fois : une fois affichées en direct sur la console, une fois ajoutées à `/tmp/test.log` — la preuve que les deux sorties se produisent vraiment en même temps, et non l'une après l'autre. Pour l'illustration ci-dessus, le script a listé un petit dossier de démo au lieu de `/tmp` pour des raisons de lisibilité ; en pratique, vous le pointerez vers la commande de longue durée que vous voulez tracer. ## Pourquoi ça fonctionne {#why-it-works} La commande passe par `eval`, et sa sortie est redirigée vers une boucle `while read`. Pour chaque ligne qui en sort, la boucle fait deux choses immédiatement, avant de lire la ligne suivante : elle l'affiche avec `echo` sur la console, puis elle l'ajoute au fichier de log. Rien n'est mis en mémoire tampon pour être vidé à la fin — c'est ce qui garde les deux sorties synchronisées. Par exemple, récupérons la liste de tous les fichiers sous `/tmp`, et ce de manière récursive. Pour cela, notre commande sera `ls -alhR /tmp`. Cette commande va probablement prendre du temps et c'est justement l'objectif : lancer une commande longue et s'assurer que l'utilisateur verra la sortie à l'écran, pour qu'il sache que le script fait quelque chose, tout en redirigeant aussi la sortie vers un fichier de log pour une analyse ultérieure en cas de problème (ou simplement parce que le script est lancé dans un cron). *Si « le script fait quelque chose » est tout ce que votre utilisateur a besoin de savoir, une [barre de progression](/blog/bash-progression-bar) est souvent plus agréable qu'un mur de texte qui défile.* **script.sh** ```bash #!/usr/bin/env bash set -euo pipefail declare -r command="ls -alhR /tmp" declare -r LOGFILE="/tmp/test.log" touch "${LOGFILE}" function log::write() { printf '[%s] %s%s\n' "$(date +"%Y-%m-%dT%H:%M:%S%z")" "${line}" >>"${LOGFILE}" return 0 } eval "${command}" | while IFS= read -r line; do echo "${line}" # Output on the console (stdout) log::write "${line}" # And append in the log file done ``` La partie principale est la fonction `eval`. La commande (`ls -alhR /tmp`) sera lancée et, pour chaque ligne, nous ferons un `echo` de la ligne à l'écran et la redirigerons aussi dans un fichier texte. L'exemple ci-dessous fait presque la même chose, c'est-à-dire qu'il exécute une commande et l'affiche à la fois dans un fichier et sur la console mais... comme vous le voyez, la commande est lancée et tout est redirigé vers le fichier. Donc, pendant plusieurs secondes, l'utilisateur n'a aucune sortie à l'écran et peut penser que le script est bloqué. Ensuite, une fois la commande terminée avec succès, et seulement alors, le fichier de log est affiché, d'un bloc, sur la console. **script.sh** ```bash #!/usr/bin/env bash set -euo pipefail declare -r command="ls -alhR /tmp" declare -r LOGFILE="/tmp/test.log" eval "${command}" > "${LOGFILE}" cat "${LOGFILE}" ``` Ne faites pas ce genre de choses ! --- # Linux - Comparer deux versions du même script > Utilisez une astuce simple en ligne de commande Linux avec diff, grep et awk pour comparer deux versions du même script Bash. Identifiez rapidement les fonctions manquantes ou nouvelles entre deux versions d'un fichier ou entre deux dossiers entiers. --- ![Linux - Comparer deux versions du même script](/img/v2/bash.webp) **TL;DR** Cet article montre comment comparer deux versions du même script Bash : on extrait les noms de fonctions de chaque fichier avec `grep`/`awk`, on les trie, puis on compare les deux listes triées avec `diff --side-by-side`. On voit ainsi quelles fonctions ont été ajoutées, supprimées ou renommées, quel que soit leur ordre dans le fichier. L'astuce est ensuite étendue en un script `compare.sh` qui applique la même comparaison à tous les scripts de deux dossiers. Toujours confronté au problème de devoir comparer deux versions du même script (voir l'article [Linux - Comparing two folders/files in the console](/blog/linux-diff-file-folder)), cette fois nous allons considérer que le fichier à comparer est un script Bash et que nous en avons deux versions. Et que nous avons peut-être fait évoluer les deux fichiers différemment, c'est-à-dire que l'un, l'autre, ou même les deux, ont pu être modifiés. L'objectif est donc de comparer les versions et de mettre en évidence les différences. ## Résultat {#result} ```bash {` ( FILE1=/tmp/bash/console.sh FILE2=/tmp/bash/console_v2.sh printf "\e[33;1m%-39s %s\e[0;1m\n" "Left side: \${FILE1}" "Right side: \${FILE2}" diff --side-by-side --width 83 \ <(grep -P "^(function\s+.*)\(\)" "\${FILE1}" | awk '{print \$2}' | sort) \ <(grep -P "^(function\s+.*)\(\)" "\${FILE2}" | awk '{print \$2}' | sort) ) `} ``` ![Comparer les deux versions du même script Bash et voir quelles fonctions sont dans l'un et pas dans l'autre](./images/compare_functions.webp) Deux listes de fonctions triées, côte à côte : tout ce qui n'apparaît que d'un seul côté est une fonction que l'autre version n'a pas — peu importe où elle était déclarée dans le fichier. ## Créer quelques fichiers pour cet article {#create-some-files-for-this-blog-post} Pour pouvoir reproduire les exemples utilisés dans cet article, si besoin, créez deux fichiers. Disons `/tmp/bash/console.sh` pour le premier fichier, avec ce contenu : **/tmp/bash/console.sh** ```bash #!/usr/bin/env bash function console::printCyan() { } function console::askYesNo() { } function console::printRed() { } function console::printError() { } function console::banner() { } ``` Et, pour `/tmp/bash/console_v2.sh`, nous ajoutons deux nouvelles fonctions, `printGreen` et `printBlue`, dans cet ordre : **/tmp/bash/console\_v2.sh** ```bash #!/usr/bin/env bash function console::printCyan() { } function console::askYesNo() { } function console::printRed() { } function console::printError() { } function console::banner() { } function console::printGreen() { } function console::printBlue() { } ``` ## Obtenir la liste des fonctions d'un script Bash {#get-the-list-of-functions-in-a-bash-script} Pour obtenir la liste des fonctions déclarées dans un script (disons `/tmp/bash/console.sh`), il suffit de lancer la commande ci-dessous : ```bash $ `grep -P "^(function\s+.*)\(\)" "/tmp/bash/console.sh" | awk '{print \$2}' | sort` ``` Cette commande récupère toutes les fonctions du fichier, c'est-à-dire les lignes commençant par le mot `function` suivi d'un caractère d'espacement, puis du nom de la fonction, puis de parenthèses. Et, pour finir, elle trie la liste : ![Obtenir la liste des fonctions d'un script Bash](./images/functions_list.webp) ## Comparer les deux versions du même script Bash et voir quelles fonctions sont dans l'un et pas dans l'autre {#compare-the-two-versions-of-the-same-bash-script-and-shows-which-functions-are-in-one-and-not-the-other} Peu importe l'ordre dans lequel les fonctions sont déclarées : le script ci-dessous, que vous pouvez copier-coller dans la console, va récupérer la liste des fonctions des deux fichiers, trier la liste et comparer quelles fonctions sont dans une version et absentes de l'autre — c'est la commande et le résultat affichés en haut de cet article, où l'on voit immédiatement que le second fichier compte deux ajouts. Bien sûr, si nous ajoutons une nouvelle fonction dans `console.sh` et que nous en supprimons une existante, par exemple, nous le voyons aussi : ![Comparer les deux versions du même script Bash et voir quelles fonctions diffèrent entre les deux fichiers](./images/compare_functions_both_side.webp) Sur l'image ci-dessus, on voit trois indicateurs au milieu de l'écran : ```diff > console::printBlue() console::printPurple() | console::printGreen() console::verbose() | console::printRed() ``` La ligne avec l'indicateur `>` signifie que `console::printBlue()` n'a été trouvée que dans le second fichier : notre premier fichier ne déclarait donc pas `printBlue`. Et les lignes avec l'indicateur `|` signalent une divergence. Des deux côtés, nous avions trié notre liste : si la même fonction avait été présente des deux côtés, nous aurions eu une correspondance. Ce n'est pas le cas ici. D'un côté nous avons par exemple `printPurple` alors que de l'autre nous avons `printGreen`. Et nous avons `verbose` et, de l'autre côté, `printRed`. On peut en conclure que, dans le premier fichier, nous n'avons pas `printGreen` ni `printRed` et que nous n'avons pas `printPurple` ni `verbose` dans le second. En ajoutant `printBlue`, `printGreen` et `printRed` dans notre premier fichier, on obtient maintenant : ![Comparer les deux versions du même script Bash et voir quelles fonctions diffèrent entre les deux fichiers](./images/compare_functions_both_side_bis.webp) On voit donc apparaître un nouvel indicateur `<` : la fonction n'a été trouvée que dans le premier fichier et pas dans le second. Une fois de plus, nous avons la confirmation que nous avons deux nouvelles fonctions (`printPurple` et `verbose`) dans notre premier fichier et pas dans le second. À ce stade, notre `/tmp/bash/console.sh` contient ceci : **/tmp/bash/console.sh** ```bash #!/usr/bin/env bash function console::printCyan() { } function console::askYesNo() { } function console::printPurple() { } function console::verbose() { } function console::printError() { } function console::banner() { } function console::printBlue() { } function console::printGreen() { } function console::printRed() { } ``` et, pour `/tmp/bash/console_v2.sh` : **/tmp/bash/console\_v2.sh** ```bash #!/usr/bin/env bash function console::printCyan() { } function console::askYesNo() { } function console::printRed() { } function console::printError() { } function console::banner() { } function console::printGreen() { } function console::printBlue() { } ``` > **L'ordre n'a pas d'importance:** > > Contrairement à une comparaison avec `diff`, l'ordre dans lequel les fonctions apparaissent dans les scripts n'a aucune importance, puisque nous les trions. *Si vous préférez corriger la cause plutôt que la contourner, [Linux - Sort functions in a Bash script](/blog/linux-sort-functions-in-script) réordonne les fonctions directement dans les fichiers.* ## Comparer les scripts Bash de deux dossiers {#compare-bash-scripts-in-two-folders} Dans le chapitre précédent, nous avons vu comment comparer deux versions du même script. Allons un cran plus loin et comparons deux dossiers : pour chaque script des deux dossiers, lançons une comparaison. Pour cela, créez le script `compare.sh` sur votre disque dur avec ce contenu : **compare.sh** ```bash #!/usr/bin/env bash clear if [ ! $# -eq 2 ]; then echo "Usage: $0 " exit 1 fi sourceFolder="$1" compareWithFolder="$2" [[ ! -d "${sourceFolder}" ]] && echo "Error: Source folder ${sourceFolder} not found." && exit 1 [[ ! -d "${compareWithFolder}" ]] && echo "Error: CompareWith folder ${compareWithFolder} not found." && exit 1 pushd "${sourceFolder}" >/dev/null || exit 1 printf "\e[37;1m%s\e[0;1m\n\n" "Compare .sh scripts and detects functions that are in one of the files but not in the other between these two folders:" printf "\e[37;1m%s\e[0;1m\n" "LEFT SIDE = ${sourceFolder}" printf "\e[37;1m%s\e[0;1m\n\n" "RIGHT SIDE = ${compareWithFolder}" printf "\e[37;1m%s\e[0;1m\n\n" "Legend:" printf "\e[37;1m1/ \e[34;1m%s\e[0;1m\n" "name1 <" printf "\e[37;1m\t%s\e[0;1m\n\n" "That function only exist in the first file; not in the second one" printf "\e[37;1m2/ \e[34;1m%s\e[0;1m\n" "name1 | name2" printf "\e[37;1m\t%s\e[0;1m\n\n" "The function name1 only exists in the first file and name2 only exists in the second file" printf "\e[37;1m3/ \e[34;1m%s\e[0;1m\n" " > name2" printf "\e[37;1m\t%s\e[0;1m\n\n" "The function name2 only exists in the second file" printf "\e[41;1m%s\e[0;1m\n" "Warning: this script checks whether a function is present in one of the files and not in the other," printf "\e[41;1m%s\e[0;1m\n" "but does not check whether the function code is identical. It just checks whether it is present or not." for bashScript in *.sh; do if [[ -f "${compareWithFolder}/${bashScript}" ]]; then ( FILE1="${sourceFolder}/${bashScript}" FILE2="${compareWithFolder}/${bashScript}" printf "\e[32;1m%s\e[0;1m\n" "File ${bashScript}" diff --suppress-common-lines --side-by-side --width 83 \ <(grep -P "^(function\s+.*)\(\)" "${FILE1}" | awk '{print $2}' | sort) \ <(grep -P "^(function\s+.*)\(\)" "${FILE2}" | awk '{print $2}' | sort) && \ printf "\e[32;1m%s\e[0;1m\n" "The two files are identical" ) fi done popd >/dev/null || exit 1 ``` Maintenant, pour l'exécuter, lancez simplement `./compare.sh foldername1 foldername2`. Vous obtiendrez quelque chose comme ci-dessous : pour chaque script présent dans les deux dossiers (les scripts présents dans un seul dossier sont ignorés), vous obtenez le nom du script (comme `array.sh`) suivi du texte `The two files are identical` si les deux fichiers sont identiques ou, sinon, une liste de noms de fonctions et l'indicateur déjà vu, à savoir `<`, `>` ou `|`. ![Comparer les scripts Bash de deux dossiers](./images/compare_folders.webp) Avec ce script, il est facile de garder l'un des deux dossiers comme *master* (le dossier de gauche), c'est-à-dire d'y reproduire toutes les fonctions qui auraient été ajoutées à un script du second dossier (celui qui correspond à la colonne de droite). Une fois une fonction identifiée, il suffit d'ouvrir le fichier de *droite* et de copier-coller la fonction dans le fichier correspondant du dossier de *gauche*. En ajoutant `| grep -E -v "<$"` à notre instruction `grep`, nous pouvons améliorer le script en ignorant les cas où des fonctions ont été ajoutées uniquement dans le dossier de gauche. Imaginez le besoin suivant : *Dans le dossier de droite, si j'ai ajouté des fonctions à mes scripts, quelles sont ces fonctions afin que je puisse les copier-coller dans les scripts du dossier de gauche ?* **compare.sh** ```bash #!/usr/bin/env bash clear if [ ! $# -eq 2 ]; then echo "Usage: $0 " exit 1 fi sourceFolder="$1" compareWithFolder="$2" [[ ! -d "${sourceFolder}" ]] && echo "Error: Source folder ${sourceFolder} not found." && exit 1 [[ ! -d "${compareWithFolder}" ]] && echo "Error: CompareWith folder ${compareWithFolder} not found." && exit 1 pushd "${sourceFolder}" >/dev/null || exit 1 printf "\e[37;1m%s\e[0;1m\n\n" "Compare .sh scripts and detects functions that are in one of the files but not in the other between these two folders:" printf "\e[37;1m%s\e[0;1m\n" "LEFT SIDE = ${sourceFolder}" printf "\e[37;1m%s\e[0;1m\n\n" "RIGHT SIDE = ${compareWithFolder}" printf "\e[37;1m%s\e[0;1m\n\n" "Legend:" printf "\e[37;1m1/ \e[34;1m%s\e[0;1m\n" "name1 <" printf "\e[37;1m\t%s\e[0;1m\n\n" "That function only exist in the first file; not in the second one" printf "\e[37;1m2/ \e[34;1m%s\e[0;1m\n" "name1 | name2" printf "\e[37;1m\t%s\e[0;1m\n\n" "The function name1 only exists in the first file and name2 only exists in the second file" printf "\e[37;1m3/ \e[34;1m%s\e[0;1m\n" " > name2" printf "\e[37;1m\t%s\e[0;1m\n\n" "The function name2 only exists in the second file" printf "\e[41;1m%s\e[0;1m\n" "Warning: this script checks whether a function is present in one of the files and not in the other," printf "\e[41;1m%s\e[0;1m\n" "but does not check whether the function code is identical. It just checks whether it is present or not." for bashScript in *.sh; do if [[ -f "${compareWithFolder}/${bashScript}" ]]; then ( FILE1="${sourceFolder}/${bashScript}" FILE2="${compareWithFolder}/${bashScript}" printf "\e[32;1m%s\e[0;1m\n" "File ${bashScript}" diff --suppress-common-lines --side-by-side --width 83 \ <(grep -P "^(function\s+.*)\(\)" "${FILE1}" | awk '{print $2}' | sort) \ <(grep -P "^(function\s+.*)\(\)" "${FILE2}" | awk '{print $2}' | sort) | grep -E -v "<$" ) fi done popd >/dev/null || exit 1 ``` Si nous lançons cette nouvelle version du script sur exactement les mêmes fichiers, les lignes se terminant par `<` sont maintenant masquées (présentes uniquement dans le dossier de gauche) et nous n'obtenons que les cas où une fonction est présente dans le dossier de droite : ![Masquer les cas où les fonctions ne sont que dans le dossier de gauche](./images/no_more_left.webp) Il devient encore plus facile d'identifier ces fonctions et de les copier-coller vers la gauche, par exemple. --- # Linux - Trier les fonctions dans un script Bash > Découvrez comment utiliser diff et sort en Bash pour vérifier si les fonctions de vos scripts shell sont définies dans l'ordre alphabétique. Avec un script pour scanner un dossier entier. --- ![Linux - Trier les fonctions dans un script Bash](/img/v2/bash.webp) **TL;DR** Cet article vérifie si les fonctions d'un script Bash sont déclarées dans l'ordre alphabétique : on extrait les noms de fonctions avec `grep`/`awk` puis on compare la liste telle quelle à une copie triée avec `diff --side-by-side`. Le tout est ensuite transformé en un script `order.sh` qui parcourt chaque fichier `.sh` d'un dossier et n'affiche rien pour les fichiers correctement triés, ce qui rend les scripts mal ordonnés faciles à repérer. Dans un [article](/blog/linux-compare-two-versions-of-the-same-script) précédent, nous avons vu une simple commande CLI pour afficher la liste des fonctions présentes dans un script. Mais qu'en est-il d'un script Bash qui scannerait un dossier entier, récupérerait tous les scripts `.sh` et vérifierait si les fonctions y sont triées ? ## Résultat {#result} `diff --side-by-side` entre l'ordre de déclaration des fonctions et l'ordre trié vous dit immédiatement si les fonctions d'un script sont classées alphabétiquement — les lignes qui ne correspondent pas pointent directement ce qu'il faut déplacer : ![Bad sorter](./images/bad_sorter.webp) Corrigez l'ordre et relancez jusqu'à ce que chaque fonction soit alignée : vous obtenez alors une confirmation sympathique : ![Congratulations](./images/congratulations.webp) ## Créer un simple script Bash pour jouer avec cet article {#create-a-simple-bash-script-to-play-with-this-blog-post} Créez le fichier `/tmp/bash/console.sh` sur votre disque avec ce contenu : **/tmp/bash/console.sh** ```bash #!/usr/bin/env bash function console::printCyan() { } function console::askYesNo() { } function console::printRed() { } function console::printError() { } function console::banner() { } function console::printGreen() { } function console::printBlue() { } ``` Comme on peut le voir, nous créons juste des fonctions vides, sans ordre particulier. ## Obtenir la liste des fonctions d'un script Bash {#get-the-list-of-functions-in-a-bash-script} Pour obtenir la liste des fonctions déclarées dans un script, lancez simplement la commande ci-dessous : ```bash $ `grep -P "^(function\s+.*)\(\)" "/tmp/bash/console.sh" | awk '{print \$2}' | sort` ``` ![Get the list of functions](./images/display_list_of_functions.webp) Voilà, nous savons maintenant comment trier la liste des fonctions dans la console. Utilisons `diff` pour comparer notre fichier existant (côté gauche) et la liste triée des fonctions (côté droit) — c'est ce qui a produit la comparaison montrée en début d'article : ```bash ( FILE=/tmp/bash/console.sh printf "\e[33;1m%-39s %s\e[0;1m\n" "Left side: AS IS" "Right side: Using correct sorter" diff --side-by-side --width 83 \ <(grep -P "^(function\s+.*)\(\)" "${FILE}" | awk '{print $2}') \ <(grep -P "^(function\s+.*)\(\)" "${FILE}" | awk '{print $2}' | sort) ) ``` Comme on le voit sur l'image ci-dessus, la colonne de gauche montre que la première fonction définie dans le script `/tmp/bash/console.sh` est `console::printCyan`, suivie de `console::askYesNo` puis de `console::printRed` en troisième position (dans cet ordre) dans le script Bash. Dans la colonne de droite, on voit que la première fonction triée est `console::askYesNo`, la deuxième `console::banner`, et ainsi de suite. Retour à la colonne de gauche : les noms affichés en blanc sont déjà dans le bon ordre ! Modifions partiellement le fichier `/tmp/bash/console.sh` et réordonnons quelques fonctions : **/tmp/bash/console.sh** ```bash #!/usr/bin/env bash function console::askYesNo() { } function console::banner() { } function console::printBlue() { } function console::printCyan() { } function console::printRed() { } function console::printError() { } function console::printGreen() { } ``` Maintenant, en relançant la même commande : ![Almost correct](./images/almost_correct.webp) Il suffit de placer `console::printRed()` à la fin et tout sera bon. Et, pour obtenir un retour positif, utilisez cette version améliorée : ```bash ( FILE=/tmp/bash/console.sh printf "\e[33;1m%-39s %s\e[0;1m\n" "Left side: AS IS" "Right side: Using correct sorter" diff --side-by-side --width 83 \ <(grep -P "^(function\s+.*)\(\)" "${FILE}" | awk '{print $2}') \ <(grep -P "^(function\s+.*)\(\)" "${FILE}" | awk '{print $2}' | sort) && printf "\n%s\n" "🎉 🎊 🕺 💃 👏 CONGRATULATIONS" ) ``` Si vous voyez le message de confirmation affiché en début d'article, parfait : les fonctions sont correctement ordonnées dans votre script. ## Traiter tous les scripts d'un dossier donné {#process-all-scripts-from-a-specific-folder} Nous venons de voir, en ligne de commande, comment vérifier si un script Bash déclarant des fonctions le fait dans l'ordre alphabétique. Passons à l'industrialisation de ce concept : un script qui va scanner chaque fichier .sh d'un dossier donné et vérifier si les fonctions y sont définies dans l'ordre alphabétique. *Un script trié est aussi un script qui se documente mieux : [Linux - Generate documentation from Bash scripts](/blog/linux-generate-documentation-from-bash-scripts) génère une page Markdown par script à partir du bloc de documentation de chaque fonction, dans l'ordre de déclaration.* Si c'est le cas, nous n'afficherons rien afin de ne pas polluer notre console. Si ce n'est pas le cas, la partie gauche de l'écran montre l'ordre actuel des déclarations de fonctions et la partie droite montre l'ordre attendu, trié alphabétiquement. Pour cela, créez le script `order.sh` sur votre disque dur avec ce contenu : **order.sh** ```bash #!/usr/bin/env bash clear if [ ! $# -eq 1 ]; then echo "Usage: $0 " exit 1 fi sourceFolder="$1" [[ ! -d "${sourceFolder}" ]] && echo "Error: Source folder ${sourceFolder} not found." && exit 1 printf "\e[37;1m%s\e[0;1m\n\n" "Check if functions declared in Bash .sh script in folder ${sourceFolder} are correctly ordered in the file." printf "\e[33;1m%-39s %s\e[0;1m\n\n" "Left side: AS IS" "Right side: Using correct sorter" pushd "${sourceFolder}" >/dev/null set +e for bashScript in *.sh; do if ! result="$(diff --side-by-side --width 83 \ <(grep -P "^(function\s+.*)\(\)" "${bashScript}" | awk '{print $2}') \ <(grep -P "^(function\s+.*)\(\)" "${bashScript}" | awk '{print $2}' | sort))"; then printf "\e[33;1m%s\e[0;1m\n" "The file ${bashScript} isn't correctly ordered" printf "\e[37;1m%s\e[0;1m\n" "${result}" fi done set -e popd >/dev/null ``` Et maintenant, lancez le script comme ceci : `./order.sh ~/helpers`. Le paramètre attendu est le nom d'un dossier contenant des fichiers `.sh`. ![Running the batch script](./images/batch_script.webp) Qu'est-ce que cela signifie ? Mon script `~/helpers/api.sh` est en fait (côté gauche) vraiment mal trié puisqu'il y a beaucoup de différences avec la colonne de droite (l'ordre parfait). La première fonction de mon fichier est `api::__injectLogsToApplicationLog` alors qu'il y a une fonction `api::__assertHttpMethod` plus loin dans le code. En éditant mon fichier et en déplaçant `api::__assertHttpMethod` au début de mon script, puis en relançant le script : ![Move the assert method first](./images/api_move_assert_first.webp) Bien, maintenant, toujours à droite, on voit que `api::__debugCurlStatement` est attendue en deuxième position, `api::__doSomeCleaning` en troisième, puis `api::__executeCall()` et ainsi de suite (comme on le voit côté droit). Dès que le fichier `api.sh` est correctement trié, relancer le script ne mentionnera plus ce script, mais d'autres. ![The console.sh script isn't ordered](./images/console.webp) L'objectif final est donc celui-ci : nous n'attendons aucune sortie du script. Si tous les scripts Bash sont correctement triés, alors le script `order.sh` ne trouvera plus aucune différence, ce qui est exactement ce que nous voulons. --- # Linux - Générer de la documentation à partir de scripts Bash > Apprenez à générer automatiquement la documentation de vos scripts Bash sous Linux. Ce guide fournit un script maison qui analyse les blocs de doc des fonctions et crée des fichiers Markdown structurés. --- ![Linux - Générer de la documentation à partir de scripts Bash](/img/v2/bash.webp) **TL;DR** Cet article partage un script Bash maison, `generate_doc.sh`, qui parcourt un dossier de fichiers `.sh`, extrait les blocs de commentaires façon PHP Docblock placés au-dessus de chaque fonction et génère un fichier Markdown par script (plus un `readme.md` récapitulatif) — de quoi combler l'absence d'équivalent Bash à des outils comme phpDocumentor. Quand j'écris des scripts Bash, je place toujours un bloc de description devant chaque fonction, comme je le fais dans n'importe quel langage (pensez au [PHP Docblock](https://docs.phpdoc.org/guide/getting-started/what-is-a-docblock.html)). Avec PHP, il existe quelques outils comme phpDocumentor pour extraire ces blocs et générer de la documentation, mais est-ce que de tels outils existent pour Bash ? Je ne sais pas, je n'en ai trouvé aucun. J'ai donc écrit un petit script Bash pour le faire : parcourir chaque fichier `.sh` présent dans un dossier, extraire les blocs de doc et créer un document Markdown pour chaque script trouvé. La documentation de chaque fonction est copiée en Markdown, puis une table des matières est ajoutée et, enfin, un fichier readme.md générique affiche la liste des fichiers Markdown récupérés. ## Résultat {#result} Le script exécuté sur un dossier de fichiers `.sh` : ![Générer la documentation](./images/generate_doc.webp) Pour un fichier trouvé dans le dossier `helpers`, un fichier correspondant est créé dans le dossier `documentation` — ici, `string.md`, avec chaque fonction disposant d'un bloc de doc : **documentation/string.md** ```markdown # Documentation string.sh ## List of functions * [public function string::upper](#public-function-stringupper) * [Description](#description) * [Examples](#examples) * [Return](#return) * [Exit code](#exit-code) ### public function string::upper #### Description Make a string uppercase. #### Examples echo "HEllow WoRLD!" | string::upper #Output HELLO WORLD! title="$(echo "Project title" | string::upper)" #### Return The string in uppercase #### Exit code * 0 If successful. * 1 An error has occurred. https://github.com/jmcantrell/bashful/blob/master/bin/bashful-utils#L33 ``` Et une fois tous les fichiers `.sh` traités, un dernier fichier `documentation/readme.md` est créé : **documentation/readme.md** ```markdown # List of helpers * [string.md](string.md) ``` ## Comment le reproduire {#how-to-reproduce-it} Prenons ce fichier `/tmp/bash/helpers/string.sh` : **/tmp/bash/helpers/string.sh** ````bash # region - public function string::upper # # ## Description # # Make a string uppercase. # # ## Examples # # ```bash # echo "HEllo WoRLD!" | string::upper # #Output # HELLO WORLD! # # title="$(echo "Project title" | string::upper)" # ``` # # ## Return # # The string in uppercase # # ## Exit code # # * 0 If successful. # * 1 An error has occurred. # # https://github.com/jmcantrell/bashful/blob/master/bin/bashful-utils#L33 # # endregion function string::upper() { tr '[:lower:]' '[:upper:]' } ```` Il faut maintenant créer le script `generate_doc.sh`. Comme c'est un gros script, cliquez sur la ligne suivante pour en voir le contenu. Cliquez à nouveau pour masquer le code. Créez le fichier `/tmp/bash/generate_doc.sh` avec ce contenu : **/tmp/bash/generate\_doc.sh** ````bash #!/usr/bin/env bash # Get the location (foldername) of this script; not the running one (don't use $0) #! Should be defined here and in below during the initialisation of the params array SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd)" # region - public function array::sort # # ## Description # # Sort an array (not associative one) # # ## Examples # # ```bash # arr=("f" "a" "e" "c" "b") # # #! As you can see, don't use the dollar sign before the array variable name # array::sort arr # sort the array "a", "b", "c", ... # ``` # # ## Parameters # # * @arg array Non-associative array to sort # # ## Return # # The sorted array # # ## Exit code # # * 0 If successful. # * 1 An error has occurred. # * 2 Function missing arguments. # # endregion function array::sort() { local -n arr=$1 # shellcheck disable=SC2207 IFS=$'\n' arrSorted=($(sort <<< "${arr[*]}")) unset IFS echo "${arrSorted[@]}" } # region - private function bash::__getIntroBlock # # ## Description # # Extract the introduction block present in the bash file. # # The block will look like below # # # region - Intro block ------------------------------------------- # # # # A long description # # # # endregion - Intro block ---------------------------------------- # # Just like in this script, see the top of this file # # ## Examples # # ```bash # bash::__getIntroBlock "any_script.sh" # ``` # # ## Parameters # # * @arg string The name of the script like "any_script.sh" # # ## Return # # The introduction block as a string # # ## Exit code # # * 0 If successful i.e. no syntax error retrieved in the script # * 1 An error has occurred i.e. there are syntax errors in the script # * 2 Function missing arguments. # # endregion function bash::__getIntroBlock() { local -r filename="$1" # The filename we need to scan (f.i. "any_script.sh") startTag="^# region - Intro block" endTag="^# endregion - Intro block" # Extract the region; including the tags regionIntroBlock="$(sed -n "/${startTag}/,/${endTag}/p" "${filename}")" # Remove the tags line regionIntroBlock="$(echo "$regionIntroBlock" | sed -E 's/^# region - Intro block.*//')" regionIntroBlock="$(echo "$regionIntroBlock" | sed -E 's/^# endregion - Intro block.*//')" # Remove the starting "#" followed or not by a space on each line # shellcheck disable=SC2001 regionIntroBlock="$(echo "$regionIntroBlock" | sed -E 's/^#\s?//')" echo "$regionIntroBlock" } # region - public function bash::addBanner # # ## Description # # Add a banner "Don't modify this file" at the top of the generated file # # ## Examples # # ```bash # bash::addBanner "documentation.md" # ``` # # ## Parameters # # * @arg string The name of the file where the banner has to be added # * @arg string Optional, the name of the script used to generate the documentation. # # ## Exit code # # * 0 If successful. # * 1 An error has occurred. # * 2 Function missing arguments. # # endregion function bash::addBanner() { local filename="$1" # The filename where the banner should be added local generateScript="${2:-}" # The name of the script used to generate the documentation # Comment that will be added at the top of the file. header="" file::addBanner "$filename" "$header\n$header2\n$header3\n$header4\n$header5\n$header6" return 0 } # region - public function bash::generateBashDocumentation # # ## Description # # Scan the script and generate a documentation (markdown one). # # ## Examples # # ```bash # bash::generateBashDocumentation "any_script.sh" # ``` # # ## Parameters # # * @arg string The name of the script like "my_script.sh" # * @arg boolean Optional, Did we need to make a table of contents? # * @arg string Optional, The start tag to retrieve like "# region - public function". # # ## Return # # When successful, the return will be a long string that is the Markdown documentation # # ## Exit code # # * 0 If successful. # * 1 An error has occurred. # * 2 Function missing arguments. # # endregion function bash::generateBashDocumentation() { local filename="$1" # The filename we need to scan (f.i. "my_script.sh") local createTOC="${2:-true}" # Should we need to create a table of contents local startTag="^${3:-# region - public function}" # The start tag if any (f.i. "# region - public function") local endTag="^# endregion" local generateScript="${2:-}" # The name of the script used to generate the documentation # Extract the region; including the tags regionBlock="$(sed -n "/${startTag}/,/${endTag}/p" "${filename}")" # Remove the starting "#" followed or not by a space on each line # shellcheck disable=SC2001 regionBlock="$(echo "$regionBlock" | sed -E 's/^#\s?//')" # Replace the starting "region -" by "# " so we've a heading one in markdown regionBlock="$(echo "$regionBlock" | sed -E 's/^region\s?-\s/# /')" # Replace any "#" in the comment by "###" i.e. increase the level regionBlock="$(echo "$regionBlock" | sed -E 's/# /### /')" # And the final "endregion" line regionBlock="$(echo "$regionBlock" | sed -E 's/^endregion//')" tmpFile="$(mktemp)" # The syntax $'\n' will concatenate substrings with a linefeed echo "# Documentation $(file::getBasename "${filename}")" > "${tmpFile}" # shellcheck disable=SC2129 bash::__getIntroBlock "${filename}" >> "${tmpFile}" if [[ ${createTOC} == "true" ]]; then # Add a table of contents # shellcheck disable=SC2129 echo "" >> "${tmpFile}" echo "## List of functions" >> "${tmpFile}" echo "" >> "${tmpFile}" markdown::generateTableOfContents "${regionBlock}" >> "${tmpFile}" echo "${regionBlock}" >> "${tmpFile}" fi bash::addBanner "${tmpFile}" "${generateScript}" cat "${tmpFile}" rm -f "${tmpFile}" return 0 } # region - public function file::addBanner # # ## Description # # Add a text at the very top of a file # # ## Examples # # ```bash # file::addBanner "test.md" "" # ``` # # ## Parameters # # * @arg string The filename where the banner should be added # * @arg string The banner text to add # # ## Exit code # # * 0 If successful i.e. the banner was successfully added # * 1 An error has occurred i.e. the banner wasn't added # * 2 Function missing arguments. # # endregion function file::addBanner() { local -r filename="$1" sed -i "1 i \\$2\n" "${filename}" return 0 } # region - public function file::getBasename # # ## Description # # Get the filename without parent folder's name. # # ## Examples # # ```bash # file::getBasename "/app/.docker/entrypoint.sh" # will return "entrypoint.sh" # ``` # # ## Parameters # # * @arg string A filename # # ## Return # # Will return only the filename, without parent folder's name # # ## Exit code # # * 0 If successful. # * 1 An error has occurred. # * 2 Function missing arguments. # # endregion function file::getBasename() { basename -- "$1" } # region - private function generate_doc::__main # # ## Description # # Run the script # # ## Examples # # ```bash # generate_doc::__main $* # ``` # # ## Parameters # # * @arg CLI arguments # # ## Exit code # # * 0 If successful. # * 1 An error has occurred. # * 2 Function missing arguments. # # endregion function generate_doc::__main() { # shellcheck disable=SC2178 arrFiles=$(find "${SCRIPT_DIR}/helpers" -name "*.sh") # And sort the array based on filenames thus arrFiles=$(array::sort arrFiles) local readme readme="# List of helpers"$'\n' [[ ! -d "${SCRIPT_DIR}"/documentation ]] && mkdir -p "${SCRIPT_DIR}"/documentation for filename in "${arrFiles[@]}"; do echo "Process $filename" # Replace the .sh extension by .md outputFilename="${filename/%.sh/.md}" # and replace "helpers" (the folder) by "documentation" (where docs will be created) outputFilename="${outputFilename/\/helpers/\/documentation}" echo "Create $outputFilename" bash::generateBashDocumentation "$filename" "true" "# region - public function" > "${outputFilename}" # Keep only the basename like "markdown.md" relativeOutputFilename="$(file::getBasename "${outputFilename}")" # $'\n' is to add a linefeed readme="${readme}"$'\n'"* [${relativeOutputFilename}](${relativeOutputFilename})" done # Generate the readme.md file so we can have a look on each doc through GitLab echo "$readme" > "${SCRIPT_DIR}/documentation/readme.md" # Add the last modification date/time in the readme.md file bash::addBanner "${SCRIPT_DIR}/documentation/readme.md" "$(basename "${BASH_SOURCE[0]}")" echo "" echo "Success! The documentation has been created/updated." return 0 } # region - public function markdown::generateTableOfContents # # ## Description # # This function will retrieve any headings (from `H2` to `H6`) from the markdown content # and will generate a table of contents # # ## Examples # # ```bash # markdown::generateTableOfContents "any markdown long, multi-lines, text" # markdown::generateTableOfContents "$(cat test.md)" # ``` # # ## Parameters # # * @arg string A long and multi-lines text # # ## Return # # A bullet list that will be used as a table of content # # For instance (partial output): # # ```markdown # * [public function console::printGray()](#public-function-console-printgray) # * [Description](#description) # * [Examples](#examples) # * [Parameters](#parameters) # * [Exit code](#exit-code) # * [public function console::printGrayNoNewLine()](#public-function-console-printgraynonewline) # * [Description](#description) # * [Examples](#examples) # ``` # # ## Exit code # # * 0 If successful. # * 1 An error has occurred. # * 2 Function missing arguments. # # endregion function markdown::generateTableOfContents() { local -r markdownContent="$1" # Extract H2 and more local headings headings="$(printf "%s" "$markdownContent" | grep '^##')" # Make sure we've Unix LF style headings="${headings//\r$/}" local toc="" # Process each entries in the list of titles (we can't yet call it a TOC) while IFS= read -r heading; do slug="" # region-slug # Try to find a slug in the title f.i. `## How to use the program?{#how-to}` # In this case, we've a slug and it's `how-to` pattern='\{\#([^}]*)\}' # Generate a slug slug="$(echo "$heading" | string::slugify)" if [[ $heading =~ $pattern ]]; then # The title already contains a slug, use it match="${BASH_REMATCH[0]}" # f.i. `{#run-hooks}` slug="${BASH_REMATCH[1]}" # f.i. `{#how-to}` # Remove the slug in the title heading=${heading//$match/} fi # endregion # H2 should become H1 in the TOC, H3 to H2, ... heading=${heading//## /# } # `# My title` should become `* My title` # `## My subtitle` should become ` * My title` # The two replace below achieve this purpose heading=${heading//# /* } heading=${heading//#/ } # heading is something like: # `* Installation` # ` * Settings` # ` * JSON file` # i.e. # 1. an indentation or not followed by a bullet followed by a space # 2. a title # Parse the title # ([^*]* ?\* ) Anything before a wildcard with or without a following space # Then a wildcard character and followed by a space ==> we've our indentation # (.*) Anything ==> we've our title pattern='([^*]* ?\* )(.*)' if [[ $heading =~ $pattern ]]; then # shellcheck disable=SC2034 indent="${BASH_REMATCH[1]}" # f.i. ` * ` title=${BASH_REMATCH[2]} # f.i. `JSON file` # Remove leading and trailing whitespace title="$(echo "$title" | string::trim)" # Add the heading in the table of content and add a LF character to go to the next line toc+=${heading//$title/[$title](#$slug)}$'\n' fi done < <(printf '%s\n' "$headings") # Process $headings, line by line echo "$toc" } # region - public function string::ltrim # # ## Description # # Removes all leading whitespace (from the left) # Specify the character to remove (default is a space) as the first argument # to remove that character; f.i. a starting slash # # ## Examples # # ```bash # $(echo " Hello world " | string::ltrim)" # "Hello world " # $(echo "/tmp/folder/" | string::ltrim "/")" # "tmp/folder/" # ``` # # ## Parameters # # * @arg String The character to remove, default is a space # # ## Return # # The left-trimmed string # # ## Exit code # # * 0 If successful. # * 1 An error has occurred. # # @link https://github.com/jmcantrell/bashful/blob/master/bin/bashful-utils#L233 # # endregion function string::ltrim() { local -r char=${1:-[:space:]} sed "s%^[${char//%/\\%}]*%%" } # region - public function string::rtrim # # ## Description # # Removes all trailing whitespace (from the right). # Specify the character to remove (default is a space) as the first argument # to remove that character; f.i. an ending slash # # ## Examples # # ```bash # $(echo " Hello world " | string::rtrim)" # " Hello world" # $(echo "/tmp/folder/" | string::rtrim "/")" # "/tmp/folder" # ``` # # ## Parameters # # * @arg String The character to remove, default is a space # # ## Return # # The right-trimmed string # # ## Exit code # # * 0 If successful. # * 1 An error has occurred. # # @https://github.com/jmcantrell/bashful/blob/master/bin/bashful-utils#L245 # # endregion function string::rtrim() { local -r char=${1:-[:space:]} sed "s%[${char//%/\\%}]*$%%" } # region - public function string::slugify # # ## Description # # Create a slug from any text # Return "how-to" f.i. the text is "How to?" # https://gist.github.com/oneohthree/f528c7ae1e701ad990e6 # # ## Examples # # ```bash # echo "HEllo WoRLD!" | string::slugify # will return `hello-world` # ``` # # ## Parameters # # * @arg string A string to "slugify" # # ## Return # # The slug i.e. a string with spaces, special characters, ... # # ## Exit code # # * 0 If successful. # * 1 An error has occurred. # endregion function string::slugify() { # Replace the "– dash" sign and use the minus instead "-" # Use sed to remove some characters then convert uppercase to lower sed -E 's/[~\^]+//g' | # Remove purely and simply some characters sed -E 's/://g' | sed -E 's/[^a-zA-Z0-9]+/-/g' | sed -E 's/^-+\|-+$//g' | sed -E 's/^-+://g' | sed -E 's/-+$//g' | tr "[:upper:]" "[:lower:]" | sed -e "s/^-//" } # region - public function string::trim # # ## Description # # Removes all leading/trailing whitespace. # Specify the character to remove (default is a space) as the first argument # to remove that character; f.i. a slash # # ## Examples # # ```bash # $(echo " Hello world " | string::trim)" # "Hello world" # $(echo "/tmp/folder/" | string::trim "/")" # "tmp/folder" # ``` # # ## Parameters # # * @arg String to trim # # ## Return # # The trimmed string (left and right) # # ## Exit code # # * 0 If successful. # #https://github.com/jmcantrell/bashful/blob/master/bin/bashful-utils#L219 # # endregion function string::trim() { local -r char=${1:-[:space:]} string::ltrim "$@" | string::rtrim "$@" } generate_doc::__main "$@" ```` Une fois le fichier `/tmp/bash/generate_doc.sh` créé et rendu exécutable (`chmod +x ./generate_doc.sh`), lancez simplement `./generate_doc.sh` dans la console — vous obtiendrez la sortie et les fichiers générés montrés plus haut. ## Pour aller plus loin {#going-further} Deux idées complémentaires, une fois que vos blocs de doc sont la seule source de vérité : * gardez vos scripts lisibles pour que la documentation générée le reste aussi ; [Linux - Sort functions in a Bash script](/blog/linux-sort-functions-in-script) présente un petit script qui réordonne les fonctions par ordre alphabétique et * transformez ce dossier de fichiers Markdown en un vrai site de documentation publié, comme décrit dans [Quarto - How I Built a Self-Documenting Ecosystem for 50+ Projects](/blog/quarto-industrialisation). --- # Linux - Tirez parti du nombre de CPU dont vous disposez ; lancez des jobs concurrents > Optimisez vos scripts Bash ! Apprenez à lancer des jobs concurrents et à exploiter tous vos cœurs CPU avec les identifiants de processus (pids), wait et nproc pour réduire drastiquement le temps d'exécution. --- ![Linux - Tirez parti du nombre de CPU dont vous disposez ; lancez des jobs concurrents](/img/v2/linux_parallel.webp) **TL;DR** Cet article montre comment exécuter des appels de fonctions Bash en parallèle plutôt qu'en séquence, en utilisant `&` pour passer chaque appel en arrière-plan, `nproc` pour dimensionner un pool de threads adapté aux cœurs CPU disponibles, et le suivi des identifiants de processus avec `jobs | wc -l` pour limiter le nombre d'exécutions simultanées — transformant une boucle séquentielle de 10 appels et 30 secondes en une version parallèle d'environ 3 secondes (avec une note pour respecter aussi les limites de débit imposées par l'API cible). Dans mon activité professionnelle, j'ai été confronté au besoin suivant : traiter chaque ligne d'un fichier CSV et faire un appel API POST pour envoyer un document. Une ligne du CSV contenait les informations à communiquer à un service API, et chaque ligne correspondait à un fichier PDF. Donc s'il y a 1000 lignes dans le fichier CSV, je dois faire 1000 appels API pour envoyer 1000 PDF. J'ai écrit mon script en Bash sous Linux puis est venu le temps d'optimiser : pas un seul appel API à la fois, mais autant que possible. Voyons comment lancer plus d'une tâche à la fois avec Bash sous Linux. Prenons cette petite démo : **demo.sh** ```bash #!/bin/bash set -euo pipefail function demo { echo "Sleeping for 3 seconds..." sleep 3 return 0 } function main { for i in {1..10}; do demo done } start_time=$(date +%s) main end_time=$(date +%s) total_time=$((end_time - start_time)) echo "Total running time: $total_time seconds" ``` Avant d'appeler la fonction `main`, je retiens l'heure de départ réelle, j'appelle `main` puis je calcule le temps écoulé en secondes. La fonction `main` est plutôt basique ici : je fais une boucle de 1 à 10 et, à chaque tour, j'appelle la fonction `demo`. Cette fonction attend 3 secondes. Donc, en lançant le script, puisqu'on appelle notre fonction demo dix fois et que la fonction attend trois secondes, la durée totale n'a rien de surprenant : ```bash Sleeping for 3 seconds... Sleeping for 3 seconds... Sleeping for 3 seconds... Sleeping for 3 seconds... Sleeping for 3 seconds... Sleeping for 3 seconds... Sleeping for 3 seconds... Sleeping for 3 seconds... Sleeping for 3 seconds... Sleeping for 3 seconds... Total running time: 30 seconds ``` > **Hé mec ! J'ai plus d'un CPU:** > > Mais ce code est complètement dépassé, non ? Combien de CPU avais-je ? Un seul ? Oh merci les dieux de l'informatique, j'en ai bien plus que ça ! Alors, pourquoi n'en utiliser qu'un ? ## Maintenant, la version optimisée {#now-the-optimized-version} Ok, maintenant, on ne va plus lancer notre fonction de manière séquentielle mais l'appeler une fois et **ne pas attendre la fin de la fonction** : on continue la boucle et on appelle la même fonction une deuxième fois, une troisième fois, ... Il faut juste s'assurer de ne pas tuer nos performances et, pour cela, on va récupérer le nombre de cœurs CPU de la machine. Sous Linux, c'est très simple : la commande `nproc` fait le travail. Sur mon ordinateur, j'ai 32 processeurs logiques et comme je peux lancer 2 threads par CPU, je peux calculer le nombre maximum de threads comme ceci : `NUMBER_OF_THREADS=$(( $(nproc) * 2 ))`. > **Attention aussi aux limitations imposées par le tiers:** > > Dans le script Bash ci-dessous, pas de souci, c'est juste mon ordinateur, mais dans mon introduction j'ai mentionné « je dois appeler un service API POST ». Ici, je m'assure simplement de ne pas être blacklisté par le serveur web. Par exemple, il y a peut-être une limitation du type « pas plus de 32 appels par seconde pour la même IP ». Dans ce cas, je dois en tenir compte et ne pas lancer plus de 32 processus à la fois (je fixerai alors `NUMBER_OF_THREADS=32`). On va adapter notre exemple comme ceci : **demo.sh** ```bash #!/bin/bash set -euo pipefail function demo { echo "Sleeping for 3 seconds..." sleep 3 return 0 } function main { # highlight-next-line # Array to store the PIDs of every jobs started in background # highlight-next-line declare -a pids # highlight-next-line pids=() # highlight-next-line # Get the number of logical cores on the machine and multiply by 2 # highlight-next-line # So if the machine has 4 cores, we'll start 8 threads in the same time # highlight-next-line NUMBER_OF_THREADS=$(( $(nproc) * 2 )) # highlight-next-line # highlight-next-line echo "Number of threads: ${NUMBER_OF_THREADS}" # highlight-next-line for i in {1..10}; do # highlight-next-line # Run our function in the background; don't wait that the function finish # highlight-next-line demo & # highlight-next-line # Remember the pid i.e. the ID of the function we've just started # highlight-next-line pids+=($!) # highlight-next-line # As soon as we've started the max of concurrent threads; wait and allow all of them to finish. # highlight-next-line if [[ $( jobs | wc -l ) -ge ${NUMBER_OF_THREADS} ]]; then # highlight-next-line for pid in "${pids[@]}"; do # highlight-next-line wait "${pid}" # highlight-next-line done # highlight-next-line # highlight-next-line # And reset the array since all jobs are done # highlight-next-line pids=() # highlight-next-line fi done # highlight-next-line # And now, make sure all parallel jobs are finished # highlight-next-line for pid in "${pids[@]}"; do # highlight-next-line wait "${pid}" # highlight-next-line done } start_time=$(date +%s) main end_time=$(date +%s) total_time=$((end_time - start_time)) echo "Total running time: $total_time seconds" ``` ### En détail {#in-depth} En ajoutant le caractère `&`, je demande à Bash de ne pas attendre que la fonction termine son travail avant de rendre la main. Donc, le code ci-dessous va exécuter `demo` 10 fois avant même que le premier appel à `demo` ne soit terminé. ```bash for i in {1..10}; do demo & done ``` Si je dois faire 100, 500 ou 1 000 appels, mon ordinateur va commencer à peiner et, peut-être, ne plus répondre du tout. Mon ordinateur ne peut pas gérer 1 000 appels en même temps. Que faire ? Je sais que j'ai un nombre précis de cœurs (voir la commande `nproc`) et je sais que je peux lancer deux threads par cœur *(donc, dans mon cas, je peux lancer 64 jobs en même temps)*. Alors, avant de lancer l'appel suivant de `demo`, je dois faire deux choses : 1. je garde l'identifiant de processus (`pid`) de la fonction `demo` qui vient d'être lancée (chaque appel a son propre `pid`) et je stocke cette valeur dans un tableau, 2. je dois vérifier combien de jobs tournent et ne sont pas encore terminés (récupéré via `$( jobs | wc -l )`) et comparer ce nombre au nombre de threads qu'on peut lancer (*64* pour moi). Dès que le nombre de jobs en cours atteint mon maximum, ok, je dois attendre. Je ne dois pas lancer un 65e job mais attendre que les 64 précédents soient terminés. Et, une fois que c'est fait, je peux continuer avec la vague suivante et ainsi de suite. Enfin, après la boucle, je fais la même chose, c'est-à-dire m'assurer que tous les jobs définis dans notre tableau `pids` sont terminés. ### Combien de secondes aurais-je attendu ? {#how-many-seconds-would-i-have-waited} Savez-vous combien de temps je dois attendre ? Rappelez-vous, dans la première version du script, j'ai attendu 30 secondes. Avec la version optimisée ci-dessus et pour faire **exactement la même chose**, j'ai attendu ... trois secondes seulement : ```bash Number of threads: 64 Sleeping for 3 seconds... Sleeping for 3 seconds... Sleeping for 3 seconds... Sleeping for 3 seconds... Sleeping for 3 seconds... Sleeping for 3 seconds... Sleeping for 3 seconds... Sleeping for 3 seconds... Sleeping for 3 seconds... Sleeping for 3 seconds... Total running time: 3 seconds ``` > **Exécuter la fonction 50 fois:** > > Dans la première version du script, en changeant la ligne `for i in {1..10}; do` en `for i in {1..50}; do`, j'attendrai 150 secondes ; non ? Avec la version optimisée, 4 secondes seulement. Pourquoi 4 et pas 3 ? Sans doute un léger délai introduit par le processeur (qui doit gérer 50 threads concurrents). > > Comme les jobs se terminent maintenant dans le désordre, deux articles complémentaires deviennent utiles : [Linux - Using a progression bar in your script](/blog/bash-progression-bar) pour garder un œil sur l'avancement global et [Bash - Script to add logging features to your script](/blog/bash-logging) pour savoir quel appel a échoué et quand. --- # Linux - Utiliser une barre de progression dans vos scripts > Ajoutez une barre de progression visuelle à vos scripts Bash sous Linux. Ce guide vous montre comment implémenter facilement une barre de progression pour suivre des jobs concurrents, visualiser l'avancement et améliorer votre interface dans le terminal. --- ![Linux - Utiliser une barre de progression dans vos scripts](/img/v2/linux_progress_bar.webp) **TL;DR** Cet article de suivi ajoute une barre de progression visuelle aux scripts Bash (dans la continuité de l'article précédent sur les jobs parallèles) à l'aide d'un helper réutilisable `progress_bar.sh` : sourcez-le, faites passer la sortie de votre boucle dans `progress_bar::process "Doing some stuff" 50`, et affichez `Progress=N` depuis l'intérieur de la boucle pour piloter la barre — pratique pour les traitements par lots longs comme des uploads vers une API pilotés par un CSV. Dans mon article précédent, [Linux - Take advantage of the number of CPUs you have; start concurrent jobs](/blog/bash-parallel-task), nous avons vu comment lancer des jobs en parallèle. La prochaine chose sympa, c'est d'afficher une barre de progression dans votre console. Ça présente plusieurs avantages : une vue claire de ce qui est fait et de ce qui reste à faire, et une interface agréable. *Quand le nombre d'éléments n'est pas connu à l'avance, un compteur en direct est plus adapté ; voyez [Keep running and count the number of files in a folder using inotifywait](/blog/linux-inotifywait). Et pour garder une trace de ce qui s'est passé une fois la barre disparue, [Bash - Script to add logging features to your script](/blog/bash-logging).* Il y a quelques mois, j'ai trouvé cet article de blog français dans mes flux RSS : et, juste, **wow !!!** Amusons-nous avec ça. ## Résultat {#result} ![Barre de progression](./images/progression_bar.gif) C'est une simple boucle Bash, sans aucun outil externe, qui pilote une barre de progression en direct dans la console. ## Pourquoi ça fonctionne {#why-it-works} Un script doit faire trois choses pour obtenir cette barre : 1. Sourcer le script `progress_bar.sh` ([merci à ephase](https://xieme-art.org/post/bash-avance-barre-de-progression/)) ; 2. Lancer la fonction qui fait le vrai travail d'une manière particulière : pas juste `main` mais `main > >(progress_bar::process "Doing some stuff" 50)`. Le chiffre `50` doit correspondre au nombre d'itérations de la boucle ; 3. Depuis l'intérieur de cette fonction, afficher une seule chose dans la console avec `echo` : `Progress=` suivi d'un nombre (de 1 à 50) — c'est le signal que la barre lit pour avancer. Pas si compliqué, non ? ## Créer demo.sh {#create-demosh} D'abord, créez un nouveau dossier sur votre disque et déplacez-vous dedans : `mkdir /tmp/progress && cd $_`. Ensuite, créez un fichier `demo.sh` dans ce dossier et copiez/collez le script bash ci-dessous : **demo.sh** ```bash #!/bin/bash set -euo pipefail function demo { echo "Progress=$1" sleep 0.1 return 0 } function main { for i in {1..50}; do demo "$i" done } source "progress_bar.sh" main > >(progress_bar::process "Doing some stuff" 50) ``` Rendez le script exécutable en lançant `chmod +x ./demo.sh`. ## Créer progress\_bar.sh {#create-progress\_barsh} Voici le contenu à copier/coller dans un fichier appelé `progress_bar.sh`, créé dans le même dossier que `demo.sh`. Créez donc un nouveau fichier sur votre disque dur ; nommez-le `progress_bar.sh` et copiez/collez le contenu ci-dessous dedans. **progress\_bar.sh** ````bash #!/usr/bin/env bash # region - Intro block ----------------------------------------------- # # Progress bar # # ## How to use this script? # # Just include this script in your own Bash like this `source progress_bar.sh` # # Now, you can use any public functions defined here like `progress_bar::draw``. # # ## Example # # The example below will just loop for 1 till 50 and the most important part is the "Progress=${i}" # echo statement. The progression of the bar depends on a regex defined in the progress_bar::process() function. # # ```bash # function main() { # for i in {1..50} # do # echo "Progress=${i}" # sleep 0.1 # done # } # # main > >(progress_bar::process "Doing some stuff" 50) # ``` # # ## Namespace # # `progress_bar::` # # cspell:ignore endregion # # shellcheck disable=SC1090,SC1091 # # ## Source # # The source comes from a French blog post: # https://xieme-art.org/post/bash-avance-barre-de-progression/ # # shellcheck disable=SC2059 # # endregion - Intro block -------------------------------------------- # let script exit if an unset variable is used or if a command fails set -o nounset set -o errexit # The different bar to display (done) PROGRESS_BAR_CHAR=(█ ▒) # Show or not the introduction text (f.i. "Doing some stuff") PROGRESS_BAR_DISPLAY_INFO=1 PROGRESS_BAR_DISPLAY_COMP=1 # Display template PROGRESS_BAR_INFO_TEMPLATE=' %s [ %3d/%3d ] ' PROGRESS_BAR_COMP_TEMPLATE=' %3d%% ' PROGRESS_BAR_TEMPLATE='\033[1m%s%s\033[0m%s%s\r' # region - private function progress_bar::__change_column_size # # ## Description # # This function will capture de WINCH signal. That one is fired by Linux when # the size of the window (i.e. the console) is updated so allow us to retrieve the # max. number of columns that can fit in the window. # # endregion function progress_bar::__change_column_size() { printf >&2 "%${COLUMNS}s" "" printf >&2 "\033[0K\r" COLUMNS=$(tput cols) } # region - private function progress_bar::__draw # # ## Description # # Draw the progress bar / progression # # endregion function progress_bar::__draw() { # function parameters local -r progress=${1?"progress is mandatory"} local -r total=${2?"total elements is mandatory"} local -r info=${3:-"In progress"} # function local variables local progress_segment local todo_segment local info_segment="" local comp_segment="" if [[ ${PROGRESS_BAR_DISPLAY_INFO:-1} -eq 1 ]]; then printf -v info_segment "${PROGRESS_BAR_INFO_TEMPLATE}" \ "$info" "$progress" "$total" fi local -r progress_ratio=$((progress * 100 / total)) if [[ ${PROGRESS_BAR_DISPLAY_COMP:-0} -eq 1 ]]; then printf -v comp_segment "${PROGRESS_BAR_COMP_TEMPLATE}" \ "$progress_ratio" fi # progress bar construction # calculate each element sizes, bar must fit in ou screen local -r bar_size=$((COLUMNS - ${#info_segment} - ${#comp_segment})) local -r progress_segment_size=$((bar_size * progress_ratio / 100)) local -r todo_segment_size=$((bar_size - progress_segment_size)) printf -v progress_segment "%${progress_segment_size}s" "" printf -v todo_segment "%${todo_segment_size}s" "" printf >&2 "$PROGRESS_BAR_TEMPLATE" \ "$info_segment" \ "${progress_segment// /${PROGRESS_BAR_CHAR[0]}}" \ "${todo_segment// /${PROGRESS_BAR_CHAR[1]}}" \ "$comp_segment" } # region - public function progress_bar::process # # ## Description # # Display a progress bar # # ## Example # # ```bash # function main() { # for i in {1..50} # do # echo "Progress=${i}" # sleep 0.1 # done # } # # main > >(progress_bar::process "Doing some stuff" 50) # ``` # # ## Parameters # # * @arg $1 The text to display like "Upload in progress" # * @arg $2 The number of positions in the bar (f.i. if there are 4 rows to proceed, it has to be initialize to 4) # # endregion function progress_bar::process() { local -r action="${1:-Upload in progress}" local -r max=${2:-100} # Capture the resize of the window so the progress bar becomes responsive trap progress_bar::__change_column_size WINCH while read -r line; do # Each line with "Progress=(digit)" will increase the progress bar if [[ $line =~ Progress=([[:digit:]]{1,}) ]]; then progress_bar::__draw "${BASH_REMATCH[1]}" "${max}" "${action}" fi done } # This script didn't contains executable code; only helpers. sourced=0 (return 0 2> /dev/null) && sourced=1 || sourced=0 if [ "${sourced}" -eq "0" ]; then printf "\033[1;31m%s\033[0m\n" "ERROR, the $0 script is meant to be sourced. Try 'source $0' and use public functions." >&2 # Write on stderr exit 1 fi return 0 ```` ## Place à l'exécution {#time-to-run-the-code} Retournez simplement dans la console, lancez `./demo.sh` et... wow, c'est vraiment chouette — vous obtenez la barre montrée plus haut. Dans un cas réel, j'ai implémenté cette fonctionnalité dans un script Bash où je dois lire un fichier CSV et, pour chaque enregistrement récupéré dans le fichier, lancer une commande `curl` pour appeler une API RESTful en POST et uploader un PDF. Et, quand le script est lancé de manière interactive, la sortie à l'écran est vraiment pratique et terriblement efficace. Bien sûr, j'utilise aussi un fichier de log où je redirige un maximum d'informations. --- # Linux - Scripts Bash - Lancer des tests unitaires avec bats/bats > Découvrez comment tester unitairement vos scripts Bash sous Linux avec le puissant framework bats (Bash Automated Testing System). Exemples concrets d'assertions, de vérifications de sortie et d'échecs. --- ![Linux - Scripts Bash - Lancer des tests unitaires avec bats/bats](/img/v2/bash.webp) **TL;DR** Cet article propose une introduction complète aux tests unitaires de scripts Bash avec le framework `bats-core`. Il montre comment démarrer rapidement en lançant les tests dans un container Docker, sans configuration compliquée. Le guide couvre l'écriture de tests de base, l'utilisation des différents types d'assertions comme `assert_output`, `assert_failure` et `assert_equal`, et montre comment structurer des tests pour de vraies fonctions shell. Des sujets plus avancés comme le mocking de commandes, le test de sorties multilignes et colorées, la vérification du contenu de fichiers et l'usage des fonctions `setup`/`teardown` pour les fixtures sont également expliqués avec des exemples concrets. Comme tous les développeurs qui vivent en ligne de commande, j'écris des scripts Bash sous Linux. Comme tout programmeur, je suis censé écrire des tests unitaires. Bon, je dois avouer que je n'en écris que rarement. Il y a quelque temps, j'ai écrit un ensemble de scripts Bash qui forment une bibliothèque de fonctions (comme un framework) et là, l'intérêt d'avoir des tests unitaires est encore plus grand puisque ces fonctions sont censées être stables et servir de fondations à des scripts plus avancés. *Deux habitudes rendent une telle bibliothèque testable dès le départ : [limiter le nombre de paramètres des fonctions](/blog/linux-bash-too-many-function-parameters) et [documenter chaque fonction avec un bloc de doc](/blog/linux-generate-documentation-from-bash-scripts).* Dans cet article, nous voyons comment écrire des tests unitaires pour des scripts Bash. Si vous cherchez l'équivalent en PHP, voyez [Write PHP unit tests using Pest](/blog/pest_tips) ; pour des tests fonctionnels / de bout en bout plutôt que des tests unitaires, j'ai aussi parlé de Behat, [Cypress](/blog/cypress) et [des tests navigateur de Pest v4](/blog/pest-functional-testing) ; et si vous préférez qu'on vous écrive une première version de la suite de tests, [un LLM local peut générer les cas manquants](/blog/ollama-test-generator). ## Commençons par une petite mise en bouche {#lets-start-with-a-little-hors-doeuvre} Comme toujours, créons d'abord un nouveau dossier pour jouer avec quelques exemples. Lancez `mkdir -p /tmp/bats && cd $_` pour créer un dossier temporaire et y entrer ; puis lancez `code .` pour démarrer VSCode et ouvrir le dossier. Nous allons créer un fichier d'illustration simple, appelons-le `tests/simple.bats`, et copier/coller le contenu suivant : **tests/simple.bats** ```bats #!/usr/bin/env bats setup() { bats_load_library bats-support bats_load_library bats-assert } @test "Asserting it's hello" { run echo "hello" assert_output "hello" assert_success } @test "Simple.bats exists" { run ls simple.bats assert_output --regexp "[Ss]imple\.bats" assert_success } ``` Et maintenant, la partie très difficile est, ah non, en fait vraiment facile, de lancer [Bats-core](https://bats-core.readthedocs.io/en/stable/) : ```bash $ docker run --rm -it -w /code/tests -v .:/code bats/bats:latest simple.bats ``` Et... ça marche. ![Test simple](./images/simple_test.webp) ### Qu'avons-nous fait ? {#what-did-we-do} Nous avons créé un exemple très basique avec deux vérifications. La première s'appelle *Asserting it's hello* et nous avons lancé `echo "hello"` exactement comme dans un script shell. Ensuite, en lançant `assert_output "hello"`, nous vérifions que la sortie de la commande lancée est bien `hello` et c'est un succès (donc c'est `hello` et rien d'autre). C'est fait avec ce script : **tests/simple.bats** ```bats @test "Asserting it's hello" { run echo "hello" assert_output "hello" assert_success } ``` Et la seconde vérification s'appelle *Simple.bats exists* : nous faisons simplement un `ls simple.bats` et vérifions si, pour l'illustration, `Simple.bats` ou `simple.bats` est retourné dans la liste des fichiers (ceci, pour illustrer l'usage d'une expression régulière). **tests/simple.bats** ```bats @test "Simple.bats exists" { run ls simple.bats assert_output --regexp "[Ss]imple\.bats" assert_success } ``` ### Vérifier un échec {#asserting-a-failure} Bien sûr, nous pouvons aussi vérifier un échec. Modifiez votre fichier `simple.bats` comme ceci : **tests/simple.bats** ```bats #!/usr/bin/env bats setup() { bats_load_library bats-support bats_load_library bats-assert } @test "Asserting it's hello" { run echo "hello" assert_output "hello" assert_success } @test "Simple.bats exists" { run ls simple.bats assert_output --regexp "[Ss]imple\.bats" assert_success } # highlight-start @test "Removing an inexistent file" { run rm INEXISTENT_FILE assert_failure } # highlight-end ``` Donc, dans la nouvelle fonction de test, nous essayons simplement de supprimer un fichier inexistant et nous attendons, forcément, un échec : ![Vérifier un échec](./images/assert_failure.webp) ## Quelques cas d'usage réels {#some-real-world-use-cases} Imaginez l'arborescence simplifiée suivante : ```tree expanded=true showJSX=false debug=false title="" . ├── src │ └── assert.sh └── tests ├── assert.bats ``` Le fichier `src/assert.sh` contient le code shell Linux que vous voulez tester. Vos scénarios de test doivent être stockés dans le dossier `tests`. Puisque nous allons écrire des tests pour le fichier `src/assert.sh`, créons `tests/assert.bats`. Ci-dessous le contenu de `src/assert.sh`. Commençons par une fonction simple : vérifier si un binaire donné est installé sur le système ou non. Le nom du binaire doit être passé en paramètre à la fonction. **src/assert.sh** ```bash #!/usr/bin/env bash function assert::notEmpty() { local -r variable="${1:-}" local -r errorMessage="${2:-"The variable can't be empty."}" [[ -z ${variable} ]] && echo "${errorMessage}" >&2 return 0 } function assert::binaryExists() { local -r binary="${1:-}" assert::notEmpty "${binary}" \ "You should specify the name of the program whose existence needs to be checked." if [[ -z "$(command -v "$binary" || true)" ]]; then echo "The ${binary} binary wasn't found on your system." >&2 exit 1 fi return 0 } ``` Ci-dessous le contenu de `tests/assert.bats`. **tests/assert.bats** ```bats #!/usr/bin/env bats setup() { bats_load_library bats-assert bats_load_library bats-support source ./../src/assert.sh # Create a dummy file every time a test function is called tmpFile="/tmp/bats/assert.tmp" [[ ! -d "/tmp/bats" ]] && mkdir "/tmp/bats" touch "${tmpFile}" } # Once the test function has been called, remove the temporary teardown() { [[ -f "${tmpFile}" ]] && rm -f "${tmpFile}" return 0 } @test "assert::binaryExists - Assert some binaries exists" { # clear is a native Linux command run assert::binaryExists "clear" # We expect thus binaryExists return a zero exit code (=success) assert_success # and no output message since it's successful assert_output "" # Another test with the native ls command run assert::binaryExists "ls" assert_output "" assert_success } @test "assert::binaryExists - Assert some binaries didn't exist" { run assert::binaryExists "fakeProgram" # Since fakeProgram didn't exist, we expect binaryExists exit with code 1 assert_failure 1 # We also expect this, exact, error message assert_output "The fakeProgram binary wasn't found on your system." } ``` La ligne de commande : ```bash $ docker run --rm -it -w /code/tests -v .:/code bats/bats:latest assert.bats ``` En la lançant, nous attendons un succès pour binaryExists avec les commandes `clear` et `ls` (puisqu'elles sont bien installées sur notre système) et nous attendons un échec pour `fakeProgram` mais, puisque nous utilisons `assert_failure`, notre scénario de tests devrait passer. Ajoutons une vérification pour voir si une image Docker spécifique existe déjà ou non sur le système : **src/assert.sh** ```bash #!/usr/bin/env bash function assert::notEmpty() { local -r variable="${1:-}" local -r errorMessage="${2:-"The variable can't be empty."}" [[ -z ${variable} ]] && echo "${errorMessage}" >&2 return 0 } function assert::binaryExists() { local -r binary="${1:-}" assert::notEmpty "${binary}" \ "You should specify the name of the program whose existence needs to be checked." if [[ -z "$(command -v "$binary" || true)" ]]; then echo "The ${binary} binary wasn't found on your system." >&2 exit 1 fi return 0 } # highlight-start function assert::dockerImageExists() { local -r imageName="${1:-}" local -r errorMessage="${2:-The Docker image \"$imageName\" did not exist}" assert::notEmpty "${imageName}" "You should specify the name of the Docker image whose existence needs to be checked." # When the image exists, "docker images -q" will return his ID (f.i. `5eed474112e9`), an empty string otherwise if [[ "$(docker images -q "$imageName" 2> /dev/null)" == "" ]]; then echo "${errorMessage}" >&2 exit 1 fi return 0 } # highlight-end ``` et le fichier `tests/assert.bats` mis à jour : **tests/assert.bats** ```bats #!/usr/bin/env bats setup() { bats_load_library bats-assert bats_load_library bats-support source ./../src/assert.sh # Create a dummy file every time a test function is called tmpFile="/tmp/bats/assert.tmp" [[ ! -d "/tmp/bats" ]] && mkdir "/tmp/bats" touch "${tmpFile}" } # Once the test function has been called, remove the temporary teardown() { [[ -f "${tmpFile}" ]] && rm -f "${tmpFile}" return 0 } @test "assert::binaryExists - Assert some binaries exists" { # clear is a native Linux command run assert::binaryExists "clear" # We expect thus binaryExists return a zero exit code (=success) assert_success # and no output message since it's successful assert_output "" # Another test with the native ls command run assert::binaryExists "ls" assert_output "" assert_success } @test "assert::binaryExists - Assert some binaries didn't exist" { run assert::binaryExists "FakeProgram" # Since FakeProgram didn't exist, we expect binaryExists exit with code 1 assert_failure 1 # We also expect this, exact, error message assert_output "The FakeProgram binary wasn't found on your system." } # highlight-start @test "assert::dockerImageExists - Assert docker image exists - using mockup" { # Mock - we'll create a very simple docker override and return a fake ID # This will simulate the `docker images -q "AN_IMAGE_NAME"` which return # the ID of the image when found docker() { echo "feb5d9fea6a5" } run assert::dockerImageExists "A-great-Docker-Image" assert_output "" assert_success } @test "assert::dockerImageExists - Assert docker image didn't exist - using mockup" { # Mock - we'll create a very simple docker override and return a fake ID # This will simulate the `docker images -q "AN_IMAGE_NAME"` which return # the ID of the image when found; here return an empty string to simulate # a non-existent image docker() { echo "" } run assert::dockerImageExists "Fake/image" "ERROR - Bad choice, that image didn't exist" assert_failure 1 assert_output --partial "Bad choice, that image didn't exist" } # highlight-end ``` ## Plus d'exemples {#more-examples} ### assert\_equal {#assert\_equal} Vérifie simplement que les deux valeurs sont égales. Ici, nous appelons une fonction qui retourne la longueur d'un tableau et nous vérifions que c'est la valeur attendue. **tests/simple.bats** ```bats @test "array::length - Calculate the length of an array" { arr=("one" "two" "three" "four" "five") assert_equal $(array::length arr) 5 } function array::length() { local -n array_length=$1 echo ${#array_length[@]} } ``` ### assert\_failure {#assert\_failure} `assert::binaryExists` fera un exit 1 si le binaire ne peut pas être trouvé. Un message d'erreur du type *The binary can't be found* sera affiché sur la console. **tests/simple.bats** ```bats @test "Assert binary didn't exist" { # Simulate which and return an error meaning "No, that binary didn't exist on the host" which() { exit 1 } run assert::binaryExists "Inexistent_Binary" "Ouch, no, no, that binary didn't exist on the system" assert_failure assert_output --partial "Ouch, no, no, that binary didn't exist on the system" } function assert::binaryExists() { local binary="${1}" local msg="${2:-${FUNCNAME[0]} - File \"$binary\" did not exist}" [[ -z "$(which "$binary" || true)" ]] && echo "$msg" && exit 1 return 0 } ``` ### assert\_output {#assert\_output} `assert_output` a deux options bien pratiques : `--partial` et `--regexp`. Utiliser `--partial` permet par exemple de vérifier que la sortie contient un texte brut donné, comme une phrase précise dans un écran d'aide. **tests/simple.bats** ```bats @test "Show the help screen" { source 'src/helpers.sh' arrArguments=("--help") run helpers::showHelp ${arrArguments[@]} assert_output --partial "Convert a revealJs slideshow to a PDF document" } ``` Utiliser `--regexp` permet d'employer une expression régulière : **tests/simple.bats** ```bats @test "Show the help screen" { source 'src/helpers.sh' arrArguments=("--input InvalidFile") run helpers::__process ${arrArguments[@]} assert_output --regexp "ERROR - The input file .* doesn't exist." } ``` ### assert\_success {#assert\_success} `assert::binaryExists` retourne 0 quand le binaire est trouvé. La fonction s'exécute en silence (aucune sortie). **tests/simple.bats** ```bats @test "Assert binary exists" { run assert::binaryExists "clear" # clear is a native Linux command assert_output "" # No output when success assert_success } function assert::binaryExists() { local binary="${1}" local msg="${2:-${FUNCNAME[0]} - File \"$binary\" did not exist}" [[ -z "$(which "$binary" || true)" ]] && echo "$msg" && exit 1 return 0 } ``` ### Vérifier les couleurs ANSI {#check-for-ansi-colors} Imaginez le code suivant : **script.sh** ```bash __RED=31 function console::printRed() { for line in "$@"; do printf "\e[1;${__RED}m%s\e[0m\n" "$line" done } ``` Nous voulons vérifier que la ligne sera affichée en rouge. **tests/simple.bats** ```bats @test "console::printRed - The sentence should be displayed" { run console::printRed "This line should be echoed in Red" assert_output "�[1;31mThis line should be echoed in Red�[0m" assert_success } ``` ### Vérifier une sortie multiligne {#check-for-multi-line-output} Imaginez le code suivant : **script.sh** ```bash function console::banner() { printf "%s\n" "============================================" printf "= %-40s =\n" "$@" printf "%s\n" "============================================" } ``` Cela va écrire trois lignes sur la console, comme par exemple : ```bash # ============================================ # = Step 1 - Initialisation = # ============================================ ``` Pour vérifier plusieurs lignes, utilisez le tableau `$lines` comme ceci : **tests/simple.bats** ```bats @test "console::banner - The sentence should be displayed" { run console::banner "Step 1 - Initialisation" assert_equal "${lines[0]}" "============================================" assert_equal "${lines[1]}" "= Step 1 - Initialisation =" assert_equal "${lines[2]}" "============================================" assert_success } ``` ### Vérifier par rapport à un fichier {#check-against-a-file} Imaginez une fonction qui va parser un fichier et, par exemple, supprimer certains paragraphes. Nous devons vérifier que le contenu est correct, une fois la fonction lancée. Pour cela, imaginez une fonction `removeTopOfFileComments`. La fonction va parser le fichier et supprimer les commentaires HTML (``) présents en haut du fichier. Pour le test, nous allons créer un fichier avec trois lignes vides, puis un bloc de commentaire HTML, puis deux lignes vides, puis le code HTML. Donc, en supprimant le commentaire HTML, nous aurons cinq lignes vides suivies du bloc HTML : nous devons donc vérifier que notre fichier contient six lignes. L'astuce utilisée est : * `cat --show-ends --show-tabs "$tempfile"` c'est-à-dire récupérer le contenu du fichier mais avec un `$` là où il y a un saut de ligne et, ici, aussi `^I` pour les tabulations. * ensuite nous passons le résultat dans `tr "\n" "#"` : au lieu d'obtenir six lignes, nous n'en obtenons qu'une, en remplaçant les sauts de ligne par `#`. Et voilà, puisque nous avons une variable avec une seule ligne (dans notre exemple : `$#$#$#$#$#$#`), nous pouvons la comparer à ce que nous attendons : **tests/simple.bats** ```bats @test "html::removeTopOfFileComments - remove HTML comments - with empty lines" { tempfile="$(mktemp)" # Here, we'll have extra, empty, lines. They should be removed too echo '' >$tempfile echo '' >>$tempfile echo '' >>$tempfile echo ' ' >>$tempfile # We also add extra spaces after the end tag echo '' >>$tempfile echo '' >>$tempfile echo '' >>$tempfile run html::removeTopOfFileComments "$tempfile" # Get now the content of the file # We expect three empty lines (the three first) # The HTML comment has been removed # Then there are two more empty line (so we'll five empty lines) # And we'll have our "" block. # # cat --show-ends --show-tabs will show the dollar sign (end-of-line) and f.i. ^I for tabulations # tr "\n" "#" will then convert the line feed character to a hash so, in fact, fileContent will # be a string like `$#$#$#$#$#$#` fileContent="$(cat --show-ends --show-tabs "$tempfile" | tr "\n" "#")" # Once we've our string, compare the fileContent with our expectation assert_equal "$fileContent" "\$#\$#\$#\$#\$#\$#" } ``` ### Vérifier un fichier avec une regex {#check-against-a-file-using-a-regex} Un second scénario peut être : vous avez une fonction d'écriture (pensez à un fichier de log) et vous voulez vérifier la présence d'une ligne donnée dans le fichier. L'exemple ci-dessous s'appuie sur `bats-file` et sa méthode `assert_file_contains`. Cette méthode demande un nom de fichier et un motif regex. **tests/simple.bats** ```bats setup() { bats_load_library bats-support bats_load_library bats-assert bats_load_library bats-file #! "grep" without the "-P" argument seems to not support repetition like "\d{4}" # # a date like `2022-04-07` regexDate="[0-9][0-9][0-9][0-9]\-[0-9][0-9]\-[0-9][0-9]" # a time like `17:41:22` regexTime="[0-9][0-9]\:[0-9][0-9]\:[0-9][0-9]" # a time zone difference like "0200" regexUTC="[0-9]*" #! Should be [0-9][0-9][0-9][0-9] but didn't work??? # The final pattern so we can match f.i. `[2022-04-07T18:00:20+0200] ` __DATE_PATTERN="\[${regexDate}\T${regexTime}\+${regexUTC}.*\]\s" return 0 } @test "log::write - Write a line in the log" { local sentence="" sentence="This is my important message" run write "${sentence}" assert_file_exist "/tmp/bats_log.tmp" echo "${__DATE_PATTERN}${sentence}" >/tmp/regex.tmp assert_file_contains "/tmp/bats_log.tmp" "${__DATE_PATTERN}${sentence}" assert_success } ``` ### Vérifier qu'une valeur n'est PAS dans un fichier {#check-that-a-value-is-not-in-a-file} Un autre usage de assert\_failure peut être de lancer une commande comme un grep et de s'attendre à obtenir une erreur : ```bash run grep "REGEX_SOMETHING_THAT_SHOULD_BE_MISSING" "/tmp/test.log" assert_failure 1 ``` ### Quelques fonctions spéciales {#some-special-functions} #### setup {#setup} La fonction `setup` est appelée avant de lancer un test. Pour chaque fonction `@test` présente dans le scénario, la fonction `setup()` sera appelée. Dans l'exemple suivant, comme il y a deux fonctions de test, `setup()` sera appelée deux fois. **tests/simple.bats** ```bats setup() { bats_load_library bats-support bats_load_library bats-assert source 'src/env.sh' } @test "env::assertFileExists - Assert .env file exists - The path isn't initialised" { run env::assertFileExists assert_failure } @test "env::assertFileExists - Assert .env file exists - The file exists" { ENV_ROOT_DIR="/tmp" ENV_FILENAME=".env.bats.testing" touch ${ENV_ROOT_DIR}/${ENV_FILENAME} run env::assertFileExists assert_success } ``` #### teardown {#teardown} Tout comme `setup`, la fonction `teardown` sera appelée pour chaque test mais une fois le test lancé. C'est le bon endroit pour, par exemple, supprimer des fichiers créés pendant l'exécution d'un test. ```bash teardown() { rm -f /tmp/bats } ``` ### Lancer les tests {#running-the-tests} La commande `docker run --rm -it -w /code/tests -v .:/code bats/bats:latest simple.bats` va lancer le fichier `simple.bats` tandis que `docker run --rm -it -w /code/tests -v .:/code bats/bats:latest` va lancer tous les fichiers `.bats` du répertoire de travail. #### Mocking {#mocking} Nous pouvons surcharger une fonction pendant un test. Prenez le cas d'usage suivant : nous avons une fonction qui retourne 0 quand une image Docker donnée est présente sur l'host. La fonction retourne 1 et affiche une erreur sur la console si l'image n'est pas trouvée. **script.sh** ```bash function assert::dockerImageExists() { local image="${1}" local msg="${2:-The Docker image \"$image\" did not exist}" # When the image exists, "docker images -q" will return his ID (f.i. `5eed474112e9`), an empty string otherwise [[ "$(docker images -q "$image" 2>/dev/null)" == "" ]] && echo "$msg" && exit 1 return 0 } ``` Nous devons donc surcharger la réponse de docker. Quand l'image est censée être là, il suffit de retourner une chaîne non vide, n'importe quoi sauf une chaîne vide. Retournons un faux ID pour vraiment simuler la réponse de `docker images -q`. **tests/simple.bats** ```bats @test "Assert docker image exists" { # Mock - we'll create a very simple docker override and return a fake ID # This will simulate the `docker images -q "AN_IMAGE_NAME"` which return # the ID of the image when found docker() { echo "feb5d9fea6a5" } source assert.sh run assert::dockerImageExists "A-great-Docker-Image" assert_output "" # No output when success assert_success } ``` Et retournons une chaîne vide pour simuler une image inexistante. **tests/simple.bats** ```bats @test "Assert docker image didn't exist" { # Mock - we'll create a very simple docker override and return a fake ID # This will simulate the `docker images -q "AN_IMAGE_NAME"` which return # the ID of the image when found; here return an empty string to simulate # an inexistent image docker() { echo "" } source assert.sh run assert::dockerImageExists "Fake/image" "Bad choice, that image didn't exist" assert_output --partial "Bad choice, that image didn't exist" assert_failure } ``` ## Conclusion {#conclusion} Bats apporte à Bash ce qui lui a toujours manqué : un moyen de prouver qu'un script fait encore ce qu'il faisait le mois dernier. Le coût de mise en place est proche de zéro — un fichier `.bats`, l'image `bats/bats`, et aucun runner à installer — et le bénéfice apparaît la première fois qu'un refactoring casse une fonction que vous aviez oubliée. Commencez petit. Prenez le script que vous avez le plus peur de toucher, écrivez trois tests pour son cas nominal, et ajoutez-en un chaque fois que vous y corrigez un bug. C'est généralement suffisant pour transformer un fichier Bash que vous n'osiez plus modifier en un fichier que vous pouvez modifier. ---