Fonctions et scripts#

Hide code cell source

import matplotlib.pyplot as plt
import matplotlib.patches as patches
import numpy as np
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

Écrire une commande ou deux dans le terminal est une chose. Écrire un script Bash que des humains pourront lire, maintenir et exécuter en production est une discipline à part entière. Ce chapitre porte sur l’art de structurer le code Bash : le shebang, les options de robustesse, la définition et l’organisation des fonctions, la gestion de la portée des variables, le passage d’arguments, les conventions de retour de valeur, et enfin la gestion des arguments en ligne de commande avec getopts. À la fin de ce chapitre, vous disposerez de tous les éléments pour écrire des scripts professionnels, lisibles et robustes.

Le shebang : première ligne du script#

La première ligne d’un script exécutable indique au noyau quel interpréteur utiliser pour l’exécuter. Cette ligne s’appelle le shebang (ou hashbang).

Définition 28 (Le shebang)

Le shebang est une séquence de deux caractères — #! — placée en tout début de fichier, immédiatement suivie du chemin vers l’interpréteur. Quand on exécute un fichier avec ./script.sh, le noyau Linux lit les deux premiers octets : s’il trouve #!, il extrait le chemin de l’interpréteur sur la même ligne et lance cet interpréteur en lui passant le script comme argument.

La forme recommandée pour les scripts Bash est :

#!/usr/bin/env bash


Plutôt que le chemin absolu `#!/bin/bash`. La commande `env` recherche `bash` dans le `PATH`, ce qui rend le script portable sur les systèmes où Bash n'est pas dans `/bin` (macOS avec Homebrew, Nix, certains BSD).
# À éviter (non portable)
#!/bin/bash

# Recommandé (portable)
#!/usr/bin/env bash

# Pour Python (même logique)
#!/usr/bin/env python3

# Pour un script POSIX sh (le plus portable, mais pas Bash)
#!/bin/sh

Remarque 26

Sur macOS, Bash est installé par défaut en version 3.2 dans /bin/bash (pour des raisons de licence GPLv2). Si l’on veut Bash 5.x (installé via Homebrew dans /opt/homebrew/bin/bash ou /usr/local/bin/bash), l’utilisation de #!/usr/bin/env bash avec un PATH correctement configuré garantit l’utilisation de la bonne version. Cette distinction devient importante quand le script utilise des fonctionnalités de Bash 4+ comme declare -A (tableaux associatifs) ou la normalisation de casse ${var,,}.

En-tête d’un script professionnel#

Un script destiné à être partagé, maintenu ou utilisé en production doit commencer par un bloc d’en-tête structuré. Cet en-tête documente le script et configure l’environnement d’exécution pour la robustesse.

#!/usr/bin/env bash
# ==============================================================================
# nom_script.sh — Description concise de ce que fait le script
#
# Usage :
#   ./nom_script.sh [OPTIONS] <argument_obligatoire>
#
# Options :
#   -h, --help        Afficher cette aide et quitter
#   -v, --verbose     Mode verbeux
#   -o, --output DIR  Répertoire de sortie (défaut : .)
#
# Description :
#   Explication plus longue du script, de ses dépendances, de ses effets
#   de bord éventuels et de son comportement en cas d'erreur.
#
# Dépendances :
#   - bash >= 4.0
#   - curl, jq (pour les appels API)
#
# Auteur :  Prénom Nom <email@exemple.com>
# Date :    2024-03-15
# Version : 1.2.0
# Licence : MIT
# ==============================================================================

# --- Options de robustesse ---
set -euo pipefail
IFS=$'\n\t'

Les options de robustesse : set -euo pipefail#

Ces trois options, combinées, transforment Bash en un environnement beaucoup moins permissif — et beaucoup plus sûr pour la production.

Définition 29 (Options de robustesse Bash)

Les options de robustesse les plus importantes sont :

  • set -e (ou set -o errexit) : arrêt immédiat si une commande retourne un code non nul. Sans cette option, Bash ignore silencieusement les erreurs et continue l’exécution — comportement dangereux dans un script automatisé.

  • set -u (ou set -o nounset) : erreur sur les variables non définies. Sans cette option, une variable non définie est silencieusement traitée comme une chaîne vide, ce qui peut produire des effets désastreux (rm -rf "$répertoire/" si $répertoire est vide).

  • set -o pipefail : le code de retour d’un pipeline est celui de la dernière commande qui a échoué, et non celui de la dernière commande. Sans cette option, commande_échouée | grep motif retourne 0 si grep réussit, masquant l’échec.

  • IFS=$'\n\t' : redéfinir l”Internal Field Separator pour exclure l’espace. Évite le word splitting involontaire sur les noms de fichiers contenant des espaces.

# Démonstration de l'importance de set -euo pipefail

# SANS ces options (comportement par défaut dangereux)
bash << 'EOF'
variable_non_définie=""
rm -rf $répertoire/   # Si $répertoire est vide → rm -rf /  (CATASTROPHIQUE)
EOF

# AVEC set -euo pipefail
bash << 'EOF'
set -euo pipefail
echo $répertoire_non_défini   # ERREUR : variable non définie → arrêt immédiat
EOF

# Gérer les exceptions à set -e
set -e

# Méthode 1 : || true (ignorer l'erreur d'une commande spécifique)
grep "motif" fichier.txt || true   # Ne pas arrêter si grep ne trouve rien

# Méthode 2 : vérifier manuellement
if grep -q "motif" fichier.txt; then
    echo "Trouvé"
fi   # grep peut retourner 1 (non trouvé) sans provoquer l'arrêt

# Méthode 3 : désactiver et réactiver localement
set +e
commande_pouvant_échouer
résultat=$?
set -e

Définir une fonction#

Bash reconnaît deux syntaxes équivalentes pour définir une fonction :

Définition 30 (Syntaxes de définition de fonction)

Les deux syntaxes équivalentes pour définir une fonction en Bash sont :

# Syntaxe 1 : avec le mot-clé function (non POSIX mais lisible)
function nom_fonction() {
    commandes
}

# Syntaxe 2 : sans le mot-clé function (POSIX)
nom_fonction() {
    commandes
}

Les deux formes sont équivalentes en Bash. La syntaxe avec function permet d’omettre les parenthèses (function nom { ... }), mais la forme avec parenthèses est recommandée pour la clarté. Contrairement à Python ou JavaScript, les fonctions Bash n’ont pas de paramètres formels dans leur définition : les arguments sont accédés via les variables positionnelles ($1, $2, etc.) exactement comme dans le script principal.


### Fonctions simples

```bash
#!/usr/bin/env bash
set -euo pipefail

# Fonction sans argument
afficher_date() {
    echo "Nous sommes le $(date '+%A %d %B %Y à %H:%M')"
}

# Appel de la fonction
afficher_date

# Fonction avec arguments
saluer() {
    local prénom="$1"
    local politesse="${2:-Bonjour}"   # Valeur par défaut
    echo "$politesse, $prénom !"
}

saluer "Alice"           # Bonjour, Alice !
saluer "Bob" "Bonsoir"   # Bonsoir, Bob !

# Fonction utilitaire : afficher un message d'erreur sur stderr et quitter
die() {
    local message="$1"
    local code="${2:-1}"   # Code de sortie, 1 par défaut
    echo "ERREUR : $message" >&2
    exit "$code"
}

# Utilisation
[[ -f "$1" ]] || die "Fichier non trouvé : $1" 2

# Fonction de journalisation avec horodatage
log() {
    local niveau="${1:-INFO}"
    local message="$2"
    printf "[%s] [%s] %s\n" "$(date '+%Y-%m-%d %H:%M:%S')" "$niveau" "$message"
}

log "INFO" "Démarrage du script"
log "WARN" "Espace disque faible"
log "ERREUR" "Connexion refusée"

Arguments des fonctions#

Les fonctions Bash reçoivent leurs arguments de la même manière que les scripts : via les variables positionnelles $1, $2, etc. Ces variables sont locales à la fonction : elles masquent temporairement les arguments du script parent pendant l’exécution de la fonction.

Remarque 27

À l’intérieur d’une fonction, $1, $2, $#, $@ et $* font référence aux arguments de la fonction, pas aux arguments du script. En revanche, $0 conserve le nom du script (pas de la fonction). Pour accéder aux arguments du script depuis une fonction, il faut les avoir sauvegardés dans des variables avant l’appel, ou les passer explicitement comme arguments.

# Démonstration des arguments de fonction
traiter_fichiers() {
    echo "Nombre d'arguments : $#"
    echo "Premier argument : $1"
    echo "Tous les arguments :"
    for arg in "$@"; do
        echo "  - '$arg'"
    done
}

traiter_fichiers "fichier 1.txt" "fichier2.txt" "répertoire/fichier3.log"
# Nombre d'arguments : 3
# Premier argument : fichier 1.txt
# Tous les arguments :
#   - 'fichier 1.txt'
#   - 'fichier2.txt'
#   - 'répertoire/fichier3.log'

# Transmission des arguments du script à une fonction
trouver_et_traiter() {
    local motif="$1"
    local répertoire="${2:-.}"   # Défaut : répertoire courant
    find "$répertoire" -name "$motif" -type f | while IFS= read -r fichier; do
        echo "Trouvé : $fichier"
    done
}

trouver_et_traiter "*.log" "/var/log"
trouver_et_traiter "*.py"   # Cherche dans le répertoire courant

Variables locales et portée#

La gestion de la portée des variables est cruciale en Bash. Par défaut, toutes les variables sont globales : une variable définie dans une fonction est visible dans le shell parent et vice versa.

Définition 31 (Portée des variables en Bash)

En Bash, la portée des variables est contrôlée par le mot-clé local :

  • Sans local : la variable est globale. Toute modification dans une fonction affecte la variable du même nom dans le script parent.

  • Avec local : la variable est locale à la fonction courante et à ses appelées. Elle masque toute variable globale du même nom et disparaît quand la fonction retourne.

La forme local variable=valeur déclare et affecte en une seule instruction. local accepte les mêmes options que declare : local -r (readonly), local -i (entier), local -a (tableau).

# Démonstration de la portée des variables
variable_globale="globale"

fonction_démonstration() {
    # Sans local : modifie la variable globale
    variable_globale="modifiée par la fonction"

    # Avec local : variable distincte, locale à la fonction
    local variable_locale="je suis locale"
    local -i compteur=0

    echo "Dans la fonction :"
    echo "  variable_globale = $variable_globale"
    echo "  variable_locale = $variable_locale"
}

echo "Avant l'appel : variable_globale = $variable_globale"
fonction_démonstration
echo "Après l'appel : variable_globale = $variable_globale"  # Modifiée !
# echo "$variable_locale"   # Erreur ou vide : variable_locale n'existe plus

# BONNE PRATIQUE : toujours utiliser local pour les variables internes
calculer_somme() {
    local -i total=0
    local nombre
    for nombre in "$@"; do
        (( total += nombre ))
    done
    echo "$total"   # Retourner la valeur via stdout
}

résultat=$(calculer_somme 10 20 30 40)
echo "Somme : $résultat"   # 100

Variables globales dans les fonctions : declare -g#

# Déclarer une variable globale depuis une fonction (Bash 4.2+)
initialiser_config() {
    declare -g CHEMIN_LOG="/var/log/mon_script.log"
    declare -g NIVEAU_LOG="INFO"
    declare -g VERSION="1.0.0"
    declare -gA CONFIG  # Tableau associatif global
    CONFIG[timeout]=30
    CONFIG[retries]=3
}

# Après l'appel, CHEMIN_LOG, NIVEAU_LOG, VERSION et CONFIG sont globaux
initialiser_config
echo "$CHEMIN_LOG"
echo "${CONFIG[timeout]}"

Valeurs de retour des fonctions#

Les fonctions Bash peuvent « retourner » des valeurs de deux manières fondamentalement différentes :

Définition 32 (Valeurs de retour en Bash)

En Bash, il existe deux mécanismes de retour de valeur pour les fonctions :

  1. Code de retour (via return N) : un entier entre 0 et 255. Conventionnellement, 0 signifie succès et toute valeur non nulle signifie échec. Ce code est accessible via $? après l’appel. C’est le mécanisme utilisé pour indiquer le succès ou l’échec d’une opération.

  2. Retour par stdout : la fonction écrit sa valeur sur stdout avec echo ou printf, et l’appelant capture cette sortie avec la substitution de commandes $(). C’est la seule façon de retourner une chaîne arbitraire ou un nombre complexe.

Ces deux mécanismes sont complémentaires : une fonction peut retourner un code de statut (0/1) ET écrire un résultat sur stdout. L’appelant utilise $? pour le statut et $() pour la valeur.

# Retour d'un code de statut seulement
est_entier() {
    local valeur="$1"
    [[ "$valeur" =~ ^-?[0-9]+$ ]]
    # [[ ]] retourne 0 (vrai) ou 1 (faux) comme code de sortie
    # La fonction retourne automatiquement ce code
}

if est_entier "42"; then
    echo "42 est un entier"
fi

if est_entier "abc"; then
    echo "abc est un entier"
else
    echo "abc n'est pas un entier"
fi

# Retour d'une valeur par stdout
obtenir_nom_utilisateur() {
    local uid="$1"
    getent passwd "$uid" | cut -d':' -f1
    # ou : awk -F: -v uid="$uid" '$3 == uid {print $1}' /etc/passwd
}

nom=$(obtenir_nom_utilisateur 1000)
echo "Utilisateur 1000 : $nom"

# Combiner code de retour et valeur stdout
trouver_fichier() {
    local nom="$1"
    local résultat
    résultat=$(find / -name "$nom" -type f 2>/dev/null | head -1)
    if [[ -n "$résultat" ]]; then
        echo "$résultat"
        return 0   # Succès
    else
        return 1   # Échec : fichier non trouvé
    fi
}

if chemin=$(trouver_fichier "passwd"); then
    echo "Trouvé : $chemin"
else
    echo "Fichier non trouvé"
fi

# Retour de plusieurs valeurs avec des variables globales
# (moins élégant mais parfois nécessaire)
analyser_fichier() {
    local fichier="$1"
    # Utiliser declare -g ou des variables globales conventionnelles
    RÉSULTAT_LIGNES=$(wc -l < "$fichier")
    RÉSULTAT_MOTS=$(wc -w < "$fichier")
    RÉSULTAT_TAILLE=$(stat -c%s "$fichier")
}

analyser_fichier "/etc/passwd"
echo "Lignes : $RÉSULTAT_LIGNES, Mots : $RÉSULTAT_MOTS, Taille : $RÉSULTAT_TAILLE"

Rendre un script exécutable#

Un script Bash est un fichier texte ordinaire. Pour pouvoir l’exécuter directement (avec ./script.sh), il faut deux conditions : que le shebang soit correct et que les permissions d’exécution soient définies.

# Rendre un script exécutable
chmod +x mon_script.sh

# Permissions recommandées pour un script privé
chmod 700 mon_script.sh   # Lecture, écriture, exécution pour le propriétaire

# Permissions pour un script partagé
chmod 755 mon_script.sh   # Exécution pour tous, modification pour le propriétaire

# Vérifier les permissions
ls -la mon_script.sh
# -rwxr-xr-x 1 alice alice 1234 mars 15 14:30 mon_script.sh

# Exécuter directement (nécessite le shebang et chmod +x)
./mon_script.sh arg1 arg2

# Exécuter sans chmod +x (pas besoin du shebang)
bash mon_script.sh arg1 arg2

# Exécuter depuis n'importe où si dans le PATH
# (copier ou créer un lien symbolique dans ~/bin ou /usr/local/bin)
cp mon_script.sh ~/bin/mon_script
# ou :
ln -s "$(pwd)/mon_script.sh" ~/bin/mon_script

Ajouter ~/bin au PATH#

# Dans ~/.bashrc ou ~/.bash_profile
if [[ -d "$HOME/bin" ]]; then
    export PATH="$HOME/bin:$PATH"
fi

# Ou plus complètement
mkdir -p "$HOME/bin"
echo 'export PATH="$HOME/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc

source vs exécution dans un sous-shell#

Il existe deux façons fondamentalement différentes d”« exécuter » un script, avec des implications importantes sur les variables et l’environnement.

```{prf:definition} source vs sous-shell :label: definition-10-06

  • Exécution dans un sous-shell (./script.sh, bash script.sh) : le script s’exécute dans un nouveau processus enfant. Il hérite des variables exportées du shell parent, mais toutes les modifications qu’il effectue (nouvelles variables, cd, export) restent confinées au sous-shell. Quand le script se termine, ces modifications disparaissent.

  • Source (source script.sh ou . script.sh) : le script est exécuté dans le shell courant, sans créer de processus enfant. Toutes les modifications — définition de variables, changement de répertoire, définition de fonctions, modifications du PATH — affectent directement le shell courant. C’est le mécanisme utilisé pour charger des fonctions ou des configurations (~/.bashrc, ~/.bash_profile).


```bash
# Démonstration de la différence

# script_test.sh :
# #!/usr/bin/env bash
# MA_VARIABLE="définie dans le script"
# cd /tmp
# function ma_fonction() { echo "je suis une fonction"; }

# Exécution normale : les changements ne persistent pas
bash script_test.sh
echo $MA_VARIABLE       # Vide : variable non visible dans le shell courant
ma_fonction             # ERREUR : fonction non définie
pwd                     # Toujours le répertoire original

# Source : les changements persistent dans le shell courant
source script_test.sh
echo $MA_VARIABLE       # "définie dans le script"
ma_fonction             # "je suis une fonction"
pwd                     # /tmp (le cd a pris effet)

# Utilisation typique : charger des fonctions utilitaires
# Dans le script principal :
source ./lib/fonctions_communes.sh
source ./lib/fonctions_réseau.sh
# Les fonctions définies dans ces fichiers sont maintenant disponibles

Organisation en bibliothèques de fonctions#

# lib/log.sh — Bibliothèque de journalisation
# À charger avec : source lib/log.sh

LOG_NIVEAU="${LOG_NIVEAU:-INFO}"
LOG_FICHIER="${LOG_FICHIER:-/dev/stderr}"

log_debug() {
    [[ "$LOG_NIVEAU" == "DEBUG" ]] || return 0
    printf "[%s] [DEBUG] %s\n" "$(date '+%H:%M:%S')" "$*" >> "$LOG_FICHIER"
}

log_info() {
    printf "[%s] [INFO]  %s\n" "$(date '+%H:%M:%S')" "$*" >> "$LOG_FICHIER"
}

log_warn() {
    printf "[%s] [WARN]  %s\n" "$(date '+%H:%M:%S')" "$*" >&2
}

log_error() {
    printf "[%s] [ERROR] %s\n" "$(date '+%H:%M:%S')" "$*" >&2
}

# Dans le script principal
source "$(dirname "$0")/lib/log.sh"
log_info "Démarrage"
log_warn "Espace disque faible"

Gestion des arguments avec getopts#

Pour les scripts acceptant des options en ligne de commande (-v, -o fichier, --help), Bash fournit la commande intégrée getopts qui analyse les arguments selon la convention POSIX.

```{prf:definition} La commande getopts :label: definition-10-07 getopts OPTSTRING VARIABLE analyse les arguments positionnels ($@) à la recherche d’options. À chaque appel (dans une boucle while), elle :

  1. Lit la prochaine option dans $@.

  2. Stocke la lettre de l’option dans VARIABLE.

  3. Si l’option attend un argument (marqué par : après la lettre dans OPTSTRING), stocke l’argument dans $OPTARG.

  4. Retourne 0 tant qu’il reste des options, 1 quand toutes ont été traitées.

  5. $OPTIND pointe vers le premier argument non-option après le traitement.

La forme OPTSTRING est une chaîne de lettres. Chaque lettre est une option valide. Un : après une lettre indique que l’option attend un argument. Un : initial dans OPTSTRING active le mode silencieux (gestion des erreurs manuelle).


### Patron complet de gestion des arguments

```bash
#!/usr/bin/env bash
set -euo pipefail

# --- Variables par défaut ---
VERBEUX=false
RÉPERTOIRE_SORTIE="."
NOMBRE_MAX=10
FICHIER_CONFIG=""

# --- Fonction d'aide ---
afficher_aide() {
    cat <<EOF
Usage : $(basename "$0") [OPTIONS] <fichier_entrée>

OPTIONS :
    -h          Afficher cette aide et quitter
    -v          Mode verbeux
    -o DIR      Répertoire de sortie (défaut : répertoire courant)
    -n NOMBRE   Nombre maximum de lignes (défaut : 10)
    -c FICHIER  Fichier de configuration

ARGUMENTS :
    fichier_entrée  Fichier à traiter (obligatoire)

EXEMPLES :
    $(basename "$0") données.csv
    $(basename "$0") -v -o /tmp/résultats -n 50 données.csv
    $(basename "$0") -c config.yaml -v données.csv

EOF
}

# --- Analyse des options ---
while getopts ":hvo:n:c:" option; do
    case "$option" in
        h)
            afficher_aide
            exit 0
            ;;
        v)
            VERBEUX=true
            ;;
        o)
            RÉPERTOIRE_SORTIE="$OPTARG"
            ;;
        n)
            if ! [[ "$OPTARG" =~ ^[0-9]+$ ]]; then
                echo "ERREUR : -n attend un entier positif, reçu : '$OPTARG'" >&2
                exit 1
            fi
            NOMBRE_MAX="$OPTARG"
            ;;
        c)
            FICHIER_CONFIG="$OPTARG"
            ;;
        :)
            echo "ERREUR : l'option -$OPTARG attend un argument" >&2
            afficher_aide >&2
            exit 1
            ;;
        \?)
            echo "ERREUR : option inconnue : -$OPTARG" >&2
            afficher_aide >&2
            exit 1
            ;;
    esac
done

# Déplacer OPTIND pour accéder aux arguments non-options
shift $((OPTIND - 1))

# --- Vérification des arguments obligatoires ---
if [[ $# -eq 0 ]]; then
    echo "ERREUR : le fichier d'entrée est obligatoire" >&2
    afficher_aide >&2
    exit 1
fi

FICHIER_ENTRÉE="$1"

# --- Vérifications ---
[[ -f "$FICHIER_ENTRÉE" ]] || { echo "ERREUR : '$FICHIER_ENTRÉE' n'existe pas" >&2; exit 2; }
[[ -r "$FICHIER_ENTRÉE" ]] || { echo "ERREUR : '$FICHIER_ENTRÉE' non lisible" >&2; exit 2; }
[[ -d "$RÉPERTOIRE_SORTIE" ]] || mkdir -p "$RÉPERTOIRE_SORTIE"

# --- Traitement ---
$VERBEUX && echo "Traitement de : $FICHIER_ENTRÉE"
$VERBEUX && echo "Sortie vers : $RÉPERTOIRE_SORTIE"
$VERBEUX && echo "Nombre max : $NOMBRE_MAX"

head -n "$NOMBRE_MAX" "$FICHIER_ENTRÉE" > "$RÉPERTOIRE_SORTIE/résultat.txt"
echo "Traitement terminé : $RÉPERTOIRE_SORTIE/résultat.txt"

Gestion des options longues avec getopt (GNU)#

getopts (built-in POSIX) ne supporte pas les options longues (--help, --output). Pour cela, on utilise la commande externe getopt (GNU coreutils) :

#!/usr/bin/env bash
set -euo pipefail

# Analyser les options avec getopt (GNU)
OPTS=$(getopt \
    --options hvo:n: \
    --longoptions help,verbose,output:,nombre: \
    --name "$(basename "$0")" \
    -- "$@") || { afficher_aide >&2; exit 1; }

eval set -- "$OPTS"

VERBEUX=false
SORTIE="."
NOMBRE=10

while true; do
    case "$1" in
        -h | --help)
            afficher_aide
            exit 0
            ;;
        -v | --verbose)
            VERBEUX=true
            shift
            ;;
        -o | --output)
            SORTIE="$2"
            shift 2
            ;;
        -n | --nombre)
            NOMBRE="$2"
            shift 2
            ;;
        --)
            shift
            break
            ;;
        *)
            echo "Option interne non gérée : $1" >&2
            exit 1
            ;;
    esac
done

Structurer un script complet#

Voici un exemple de script bien structuré incorporant toutes les bonnes pratiques :

#!/usr/bin/env bash
# ==============================================================================
# sauvegarder.sh — Sauvegarde incrémentale d'un répertoire
# ==============================================================================

set -euo pipefail
IFS=$'\n\t'

# --- Constantes ---
readonly NOM_SCRIPT="$(basename "$0")"
readonly VERSION="2.1.0"
readonly HORODATAGE="$(date +%Y%m%d_%H%M%S)"

# --- Valeurs par défaut ---
VERBEUX=false
COMPRESSION=true
DESTINATION="/backup"
RÉTENTION_JOURS=30

# --- Fonctions utilitaires ---
log()   { printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$*"; }
warn()  { printf '[%s] WARN: %s\n' "$(date '+%H:%M:%S')" "$*" >&2; }
erreur() {
    printf '[%s] ERREUR: %s\n' "$(date '+%H:%M:%S')" "$*" >&2
    exit "${2:-1}"
}

afficher_aide() {
    cat <<EOF
$NOM_SCRIPT v$VERSION — Sauvegarde incrémentale

Usage : $NOM_SCRIPT [OPTIONS] <source>

OPTIONS :
    -d DIR    Destination (défaut : $DESTINATION)
    -j N      Rétention en jours (défaut : $RÉTENTION_JOURS)
    -c        Désactiver la compression
    -v        Mode verbeux
    -h        Aide

EOF
}

vérifier_prérequis() {
    local manquants=()
    for outil in rsync tar gzip; do
        command -v "$outil" &>/dev/null || manquants+=("$outil")
    done
    if (( ${#manquants[@]} > 0 )); then
        erreur "Outils manquants : ${manquants[*]}"
    fi
}

nettoyer_anciennes_sauvegardes() {
    local destination="$1"
    local jours="$2"
    log "Nettoyage des sauvegardes de plus de $jours jours..."
    find "$destination" -name "*.tar.gz" -mtime +"$jours" -delete
    $VERBEUX && log "Nettoyage terminé"
}

effectuer_sauvegarde() {
    local source="$1"
    local destination="$2"
    local archive="${destination}/sauvegarde_${HORODATAGE}"

    mkdir -p "$destination"
    log "Sauvegarde de '$source' vers '$archive'..."

    if $COMPRESSION; then
        tar czf "${archive}.tar.gz" -C "$(dirname "$source")" "$(basename "$source")"
        log "Archive créée : ${archive}.tar.gz ($(du -sh "${archive}.tar.gz" | cut -f1))"
    else
        tar cf "${archive}.tar" -C "$(dirname "$source")" "$(basename "$source")"
        log "Archive créée : ${archive}.tar"
    fi
}

# --- Analyse des options ---
while getopts ":d:j:cvh" opt; do
    case "$opt" in
        d) DESTINATION="$OPTARG" ;;
        j) RÉTENTION_JOURS="$OPTARG" ;;
        c) COMPRESSION=false ;;
        v) VERBEUX=true ;;
        h) afficher_aide; exit 0 ;;
        :) erreur "L'option -$OPTARG nécessite un argument" ;;
        \?) erreur "Option inconnue : -$OPTARG" ;;
    esac
done
shift $((OPTIND - 1))

# --- Validation ---
[[ $# -ge 1 ]] || erreur "Le répertoire source est obligatoire"
SOURCE="$1"
[[ -d "$SOURCE" ]] || erreur "'$SOURCE' n'est pas un répertoire"

# --- Exécution ---
vérifier_prérequis
effectuer_sauvegarde "$SOURCE" "$DESTINATION"
nettoyer_anciennes_sauvegardes "$DESTINATION" "$RÉTENTION_JOURS"
log "Sauvegarde terminée avec succès"

Visualisation : cycle de vie d’un script Bash#

Hide code cell source

fig, axes = plt.subplots(1, 2, figsize=(18, 10))
palette = sns.color_palette("muted", 8)

# ============================================================
# Schéma 1 : Cycle de vie d'un script
# ============================================================
ax = axes[0]
ax.set_xlim(-1.5, 13)
ax.set_ylim(-1, 12)
ax.axis('off')
ax.set_title('Cycle de vie d\'un script Bash', fontsize=14, fontweight='bold')

étapes = [
    (5.5, 11.0, '1. Invocation', './script.sh -v -o /tmp fichier.csv', palette[0]),
    (5.5,  9.2, '2. Parsing Shebang', '#!/usr/bin/env bash → interpréteur', palette[1]),
    (5.5,  7.4, '3. Options de robustesse', 'set -euo pipefail ; IFS=…', palette[2]),
    (5.5,  5.6, '4. Chargement des libs', 'source lib/log.sh ; source lib/utils.sh', palette[3]),
    (5.5,  3.8, '5. Parsing des arguments', 'getopts → variables OPTS', palette[4]),
    (5.5,  2.0, '6. Validation', 'Vérifier fichiers, prérequis, env.', palette[5]),
    (5.5,  0.2, '7. Exécution', 'Appels de fonctions, traitement', palette[6]),
]

for i, (x, y, titre, desc, c) in enumerate(étapes):
    # Boîte principale
    rect = patches.FancyBboxPatch(
        (x - 4.5, y - 0.55), 9.0, 1.1,
        boxstyle="round,pad=0.1", linewidth=2.0,
        edgecolor=c, facecolor=(*c[:3], 0.15)
    )
    ax.add_patch(rect)

    # Numéro de l'étape
    cercle = patches.Circle((x - 3.8, y), 0.35, color=c, zorder=5)
    ax.add_patch(cercle)
    ax.text(x - 3.8, y, str(i + 1), ha='center', va='center',
            fontsize=10, fontweight='bold', color='white', zorder=6)

    # Titre
    ax.text(x - 2.9, y + 0.15, titre, ha='left', va='center',
            fontsize=10, fontweight='bold', color=c)

    # Description
    ax.text(x - 2.9, y - 0.2, desc, ha='left', va='center',
            fontsize=8.5, color='#555555', style='italic',
            fontfamily='monospace')

    # Flèche vers l'étape suivante
    if i < len(étapes) - 1:
        ax.annotate('',
                    xy=(x, y - 0.55 - 0.15),
                    xytext=(x, y - 0.55),
                    arrowprops=dict(arrowstyle='->', color='#888888', lw=2.0))

# Code de retour en bas
ax.text(5.5, -0.7, 'exit N  (N=0 : succès ; N≠0 : erreur)',
        ha='center', va='center', fontsize=10, fontweight='bold',
        fontfamily='monospace',
        color=palette[7],
        bbox=dict(boxstyle='round,pad=0.3', facecolor=(*palette[7][:3], 0.15),
                  edgecolor=palette[7], linewidth=1.5))

# ============================================================
# Schéma 2 : Portée des variables et appels de fonctions
# ============================================================
ax2 = axes[1]
ax2.set_xlim(-1, 13)
ax2.set_ylim(-1, 12)
ax2.axis('off')
ax2.set_title('Portée des variables et appels de fonctions',
              fontsize=14, fontweight='bold')

# Shell principal
def zone(ax, x, y, w, h, couleur, titre, alpha=0.18):
    rect = patches.FancyBboxPatch(
        (x, y), w, h,
        boxstyle="round,pad=0.2", linewidth=2.5,
        edgecolor=couleur, facecolor=(*couleur[:3], alpha)
    )
    ax.add_patch(rect)
    ax.text(x + w/2, y + h - 0.35, titre,
            ha='center', va='center', fontsize=11, fontweight='bold',
            color=couleur)

# Zone script principal
zone(ax2, 0.3, 0.5, 11.5, 10.5, palette[0], 'Script principal (shell courant)')

# Variables globales
ax2.text(1.0, 10.3, 'Variables globales', ha='left', va='center',
         fontsize=9.5, fontweight='bold', color=palette[0])
vars_glob = ['VERSION="1.0"', 'VERBEUX=false', 'DESTINATION="/tmp"']
for i, var in enumerate(vars_glob):
    ax2.text(1.2, 9.8 - i * 0.45, var, ha='left', va='center',
             fontsize=9, fontfamily='monospace', color='#2c3e50')

# Appel de fonction 1
zone(ax2, 0.8, 4.5, 4.5, 4.2, palette[1], 'fonction_a()')

ax2.text(1.3, 8.1, 'local x="locale"', ha='left', va='center',
         fontsize=8.5, fontfamily='monospace', color=palette[1])
ax2.text(1.3, 7.65, 'local -i compteur=0', ha='left', va='center',
         fontsize=8.5, fontfamily='monospace', color=palette[1])
ax2.text(1.3, 7.2, 'echo "$VERBEUX"', ha='left', va='center',
         fontsize=8.5, fontfamily='monospace', color='#555555',
         style='italic')
ax2.text(1.3, 6.75, '# ↑ accès aux globales', ha='left', va='center',
         fontsize=8, color='#888888', style='italic')
ax2.text(1.3, 6.3, 'return 0', ha='left', va='center',
         fontsize=8.5, fontfamily='monospace', color=palette[1])
ax2.text(1.3, 5.85, '# x disparaît ici', ha='left', va='center',
         fontsize=8, color='#c0392b', style='italic')

# Appel de fonction 2
zone(ax2, 6.0, 4.5, 5.0, 4.2, palette[2], 'fonction_b()')

ax2.text(6.5, 8.1, 'local résultat=""', ha='left', va='center',
         fontsize=8.5, fontfamily='monospace', color=palette[2])
ax2.text(6.5, 7.65, 'résultat=$(cmd)', ha='left', va='center',
         fontsize=8.5, fontfamily='monospace', color=palette[2])
ax2.text(6.5, 7.2, 'echo "$résultat"', ha='left', va='center',
         fontsize=8.5, fontfamily='monospace', color='#555555',
         style='italic')
ax2.text(6.5, 6.75, '# stdout = valeur retournée', ha='left', va='center',
         fontsize=7.5, color='#888888', style='italic')

# Sous-shell (source vs exécution)
zone(ax2, 0.8, 1.0, 10.8, 3.0, palette[4], 'source lib.sh  vs  bash lib.sh')

ax2.text(1.2, 3.6, 'source lib.sh', ha='left', va='center',
         fontsize=9.5, fontweight='bold', fontfamily='monospace',
         color=palette[2])
ax2.text(1.2, 3.2, '→ exécution dans le shell courant', ha='left', va='center',
         fontsize=8.5, color=palette[2], style='italic')
ax2.text(1.2, 2.8, '→ variables et fonctions persistent', ha='left', va='center',
         fontsize=8.5, color=palette[2], style='italic')

ax2.text(6.5, 3.6, 'bash lib.sh', ha='left', va='center',
         fontsize=9.5, fontweight='bold', fontfamily='monospace',
         color=palette[3])
ax2.text(6.5, 3.2, '→ sous-shell séparé', ha='left', va='center',
         fontsize=8.5, color=palette[3], style='italic')
ax2.text(6.5, 2.8, '→ aucune persistance', ha='left', va='center',
         fontsize=8.5, color=palette[3], style='italic')

# Flèches d'appel
ax2.annotate('', xy=(3.05, 8.65), xytext=(3.05, 10.1),
            arrowprops=dict(arrowstyle='->', color=palette[1], lw=2.0,
                            linestyle='dashed'))
ax2.annotate('', xy=(8.5, 8.65), xytext=(8.5, 10.1),
            arrowprops=dict(arrowstyle='->', color=palette[2], lw=2.0,
                            linestyle='dashed'))

ax2.text(3.05, 9.4, 'appel', ha='center', va='center',
         fontsize=8, color=palette[1], style='italic',
         fontweight='bold')
ax2.text(8.5, 9.4, 'appel', ha='center', va='center',
         fontsize=8, color=palette[2], style='italic',
         fontweight='bold')

plt.tight_layout()
plt.show()
_images/59026f7cdc11455ede921830ed41a7ffca67718e596329241c884b2e0180d539.png

Hide code cell source

# Tableau récapitulatif des bonnes pratiques
fig, ax = plt.subplots(figsize=(16, 8))
ax.axis('off')
ax.set_title('Récapitulatif des bonnes pratiques pour les scripts Bash professionnels',
             fontsize=14, fontweight='bold', pad=20)

palette3 = sns.color_palette("muted", 6)

bonnes_pratiques = [
    {
        'catégorie': 'Shebang & robustesse',
        'couleur': palette3[0],
        'items': [
            ('#!/usr/bin/env bash', 'Portable sur tous les systèmes'),
            ('set -euo pipefail', 'Arrêt sur erreur, var. non définie, pipe échoué'),
            ('IFS=$\'\\n\\t\'', 'Évite le word splitting accidentel'),
        ]
    },
    {
        'catégorie': 'Variables',
        'couleur': palette3[1],
        'items': [
            ('"$variable"', 'Toujours entre guillemets doubles'),
            ('local var=val', 'Variables locales dans les fonctions'),
            ('readonly CONST=val', 'Constantes immuables'),
        ]
    },
    {
        'catégorie': 'Fonctions',
        'couleur': palette3[2],
        'items': [
            ('nom_explicite()', 'Noms descriptifs avec underscores'),
            ('return 0/1', 'Code de succès/échec'),
            ('echo "$résultat"', 'Retourner une valeur via stdout'),
        ]
    },
    {
        'catégorie': 'Arguments',
        'couleur': palette3[3],
        'items': [
            ('getopts ":hvo:"', 'Parsing POSIX des options'),
            ('afficher_aide()', 'Aide accessible via -h/--help'),
            ('shift $((OPTIND-1))', 'Accéder aux args non-options'),
        ]
    },
    {
        'catégorie': 'Erreurs',
        'couleur': palette3[4],
        'items': [
            ('echo "ERR" >&2', 'Erreurs sur stderr'),
            ('exit 1 / exit 2', 'Codes de sortie significatifs'),
            ('die() { echo … >&2; exit 1; }', 'Fonction utilitaire d\'erreur'),
        ]
    },
    {
        'catégorie': 'Organisation',
        'couleur': palette3[5],
        'items': [
            ('source lib.sh', 'Bibliothèques de fonctions'),
            ('# Commentaires', 'Documenter le pourquoi, pas le quoi'),
            ('En-tête structuré', 'Usage, options, auteur, version'),
        ]
    },
]

n_cols = 3
n_rows = 2
col_w = 16 / n_cols
row_h = 8 / n_rows

for idx, cat in enumerate(bonnes_pratiques):
    col = idx % n_cols
    row = idx // n_cols
    x = col * col_w / 16
    y = 1.0 - (row + 1) * row_h / 8

    c = cat['couleur']
    w_box = 0.30
    x_box = x + 0.01

    # En-tête de catégorie
    fond = patches.FancyBboxPatch(
        (x_box, y + 0.58), w_box, 0.10,
        boxstyle="round,pad=0.01", linewidth=2,
        edgecolor=c, facecolor=c, alpha=0.85,
        transform=ax.transAxes
    )
    ax.add_patch(fond)
    ax.text(x_box + w_box / 2, y + 0.63, cat['catégorie'],
            ha='center', va='center', fontsize=10, fontweight='bold',
            color='white', transform=ax.transAxes)

    for i, (code, desc) in enumerate(cat['items']):
        y_item = y + 0.44 - i * 0.16
        fond_item = patches.FancyBboxPatch(
            (x_box, y_item - 0.055), w_box, 0.13,
            boxstyle="round,pad=0.01", linewidth=1,
            edgecolor=c, facecolor=(*c[:3], 0.08),
            transform=ax.transAxes
        )
        ax.add_patch(fond_item)
        ax.text(x_box + 0.01, y_item + 0.02, code,
                ha='left', va='center', fontsize=8.5, fontweight='bold',
                color=c, fontfamily='monospace', transform=ax.transAxes)
        ax.text(x_box + 0.01, y_item - 0.025, desc,
                ha='left', va='center', fontsize=7.5, color='#555555',
                style='italic', transform=ax.transAxes)

plt.tight_layout()
plt.show()
_images/2294e45c07fa433c252b7bbfd8c75f07b9d71b496245e9aa63d0da8373378fe1.png

Résumé#

Dans ce chapitre, nous avons acquis les compétences nécessaires pour écrire des scripts Bash professionnels et robustes :

  • Le shebang #!/usr/bin/env bash est préférable à #!/bin/bash pour la portabilité. Il indique au noyau quel interpréteur utiliser pour exécuter le script.

  • L”en-tête structuré documente l’usage, les options, les dépendances et l’auteur. Les options set -euo pipefail et IFS=$'\n\t' rendent le script robuste face aux erreurs silencieuses.

  • Les fonctions se définissent avec nom() { ... } ou function nom() { ... }. Les arguments sont accessibles via $1, $@, $# — exactement comme dans le script principal.

  • Le mot-clé local est fondamental : sans lui, toutes les variables de la fonction sont globales et peuvent corrompre l’état du script. La règle est simple : toujours déclarer local toutes les variables internes à une fonction.

  • Les fonctions retournent soit un code de statut (0-255) via return, soit une chaîne via echo capturée avec $(). Les deux mécanismes sont complémentaires.

  • chmod +x rend un script exécutable. L’ajout de ~/bin au PATH permet de lancer les scripts depuis n’importe où.

  • source exécute un script dans le shell courant (les modifications persistent) ; l’exécution normale crée un sous-shell (rien ne persiste). source est utilisé pour charger des bibliothèques de fonctions.

  • getopts analyse les options POSIX (-v, -o valeur) de manière robuste. Pour les options longues (--verbose, --output), on utilise la commande externe getopt (GNU).

Dans le chapitre suivant, nous approfondirons les tableaux et la manipulation de chaînes : tableaux indexés, tableaux associatifs, et toute la richesse des opérateurs d’expansion de variables Bash pour transformer les chaînes sans outils externes.