Bonnes pratiques et ShellCheck#

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)

Bash est un langage au comportement parfois surprenant. Ses règles de quoting, son word splitting silencieux, ses globbing non sollicités et ses nombreuses subtilités historiques font que des scripts apparemment raisonnables peuvent se comporter de façon catastrophique sur des données réelles. Ce chapitre final rassemble les bonnes pratiques les plus importantes, présente les pièges classiques à éviter, et introduit ShellCheck — l’outil d’analyse statique qui détecte automatiquement la plupart de ces problèmes.

Pièges de quoting : la règle fondamentale#

Le quoting est sans doute la source de bugs la plus fréquente en Bash. La règle est simple mais doit être appliquée systématiquement.

Définition 65 (La règle d’or du quoting)

Toujours mettre les variables entre guillemets doubles : "$variable", "$@", "$(commande)". Sans guillemets, Bash applique le word splitting (découpage sur les espaces, tabulations et sauts de ligne) et le globbing (expansion des caractères *, ?, [) sur la valeur de la variable, ce qui peut produire des comportements inattendus ou destructeurs.

Word splitting#

fichier="mon fichier avec espaces.txt"

# FAUX : Bash voit deux arguments distincts : "mon" et "fichier"
cp $fichier /dest/              # Équivalent à cp mon fichier avec espaces.txt /dest/

# CORRECT : les guillemets préservent la valeur entière
cp "$fichier" /dest/

# FAUX : $@ sans guillemets ré-effectue le word splitting sur chaque argument
traiter_arguments() {
    for arg in $@; do           # Mauvais !
        echo "$arg"
    done
}

# CORRECT : "$@" préserve chaque argument comme une entité distincte
traiter_arguments() {
    for arg in "$@"; do         # Correct
        echo "$arg"
    done
}

Globbing non voulu#

# Si $pattern vaut "*.txt", ceci liste les fichiers .txt au lieu de chercher le pattern
ls "$pattern"           # Correct : cherche littéralement *.txt
ls $pattern             # DANGEREUX : s'étend en la liste des fichiers .txt

# Si une variable est vide, $@ sans guillemets peut s'étendre en rien du tout
# mais "$@" produit une liste vide, ce qui est le bon comportement

Remarque 55

Les seules situations où l’on omet intentionnellement les guillemets :

  1. À l’intérieur de [[ ]] pour les comparaisons de motifs : [[ $var == *.txt ]] (ici le globbing côté droit est un comportement voulu).

  2. Pour utiliser intentionnellement le word splitting pour découper une chaîne délimitée par des espaces — mais même là, un tableau est presque toujours préférable.

  3. Pour les variables arithmétiques dans $(( )) où le word splitting n’a pas lieu.

Dans le doute : mettre des guillemets.

Espaces dans les noms de fichiers#

Les noms de fichiers Unix peuvent contenir n’importe quel caractère sauf / et le caractère nul (\0). En pratique, les espaces, les sauts de ligne, les tabulations, les apostrophes et autres caractères spéciaux sont légaux et courants sur les systèmes des utilisateurs.

# Démonstration du problème
for f in $(ls /home/utilisateur/Documents); do
    echo "Fichier : $f"
    wc -l "$f"    # Échouera si le nom contient des espaces
done
# Si un fichier s'appelle "rapport final.pdf", la boucle voit
# "rapport" et "final.pdf" comme deux fichiers distincts.

# Correction avec glob direct (pas de ls !)
for f in /home/utilisateur/Documents/*; do
    echo "Fichier : $f"
    wc -l "$f"    # Correct grâce aux guillemets
done

# Pour les noms avec des sauts de ligne (cas extrême), utiliser find avec -print0
find /repertoire -name "*.log" -print0 | while IFS= read -r -d '' fichier; do
    echo "Traitement : $fichier"
    gzip "$fichier"
done

Exemple 34 (Lire les fichiers d’un répertoire de façon robuste)

Trois façons courantes de parcourir les fichiers d’un répertoire, de la moins à la plus robuste :

# Méthode 1 : fragile (word splitting, pas de gestion des espaces)
for f in $(ls *.txt); do echo "$f"; done

# Méthode 2 : robuste pour la plupart des cas (glob direct)
for f in *.txt; do echo "$f"; done

# Méthode 3 : maximalement robuste (gère les sauts de ligne dans les noms)
find . -maxdepth 1 -name "*.txt" -print0 \
    | while IFS= read -r -d '' f; do
        echo "$f"
    done

## `[[ ]]` plutôt que `[ ]`

Les tests en Bash peuvent s'écrire avec `[ ]` (conforme POSIX, disponible dans `sh`) ou `[[ ]]` (extension Bash, plus riche et plus sûr).

```{prf:definition} Différences entre `[ ]` et `[[ ]]`
:label: definition-20-02
`[[ ]]` est la **syntaxe de test étendue** de Bash. Elle offre plusieurs avantages sur `[ ]` :

- **Pas de word splitting ni de globbing** sur les variables à l'intérieur de `[[ ]]`, même sans guillemets. Cela dit, la convention est de toujours mettre des guillemets pour la clarté.
- **Opérateurs logiques lisibles** : `&&` et `||` au lieu de `-a` et `-o` (qui sont dépréciés dans `[ ]`).
- **Comparaison de chaînes avec patterns** : `[[ $var == *.txt ]]` avec glob côté droit.
- **Regex** : `[[ $var =~ ^[0-9]+$ ]]` pour tester une expression régulière ERE.
- **Pas de problème avec les chaînes vides** : `[[ -z $var ]]` est sûr même si `$var` est vide.
# Comparaison de chaînes
var="bonjour"
[ "$var" = "bonjour" ]      # [ ] : guillemets obligatoires
[[ $var == "bonjour" ]]     # [[ ]] : guillemets optionnels (mais recommandés)

# Opérateurs logiques
[ "$a" -gt 0 -a "$b" -lt 10 ]     # [ ] : opérateurs -a et -o (déconseillés)
[[ $a -gt 0 && $b -lt 10 ]]       # [[ ]] : && et || naturels

# Pattern matching (uniquement dans [[ ]])
fichier="rapport_2024_final.pdf"
[[ $fichier == *.pdf ]]           # Vrai si se termine par .pdf
[[ $fichier == rapport_* ]]       # Vrai si commence par rapport_

# Regex (uniquement dans [[ ]])
ip="192.168.1.100"
if [[ $ip =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then
    echo "Format IP valide"
fi

# Valeur numérique
if [[ $TIMEOUT =~ ^[0-9]+$ ]]; then
    echo "TIMEOUT est un entier positif"
fi

# Fichiers et répertoires
[[ -f "$fichier" ]]     # Fichier régulier existant
[[ -d "$repertoire" ]]  # Répertoire existant
[[ -r "$fichier" ]]     # Lisible
[[ -w "$fichier" ]]     # Accessible en écriture
[[ -x "$script" ]]      # Exécutable
[[ -s "$fichier" ]]     # Non vide (taille > 0)
[[ -L "$lien" ]]        # Lien symbolique

Ne pas analyser la sortie de ls#

L’une des erreurs les plus enseignées et les plus répandues est de vouloir analyser la sortie de ls pour obtenir la liste des fichiers d’un répertoire.

Remarque 56

Ne jamais analyser la sortie de ls. La sortie de ls est conçue pour être lue par des humains, pas parsée par des programmes. Elle peut :

  • Tronquer les noms longs selon la largeur du terminal.

  • Remplacer les caractères non imprimables par ? selon les options.

  • Changer de format selon les options, la locale et les versions du système.

  • Ne pas gérer correctement les noms contenant des sauts de ligne.

Les alternatives correctes sont les globs et find.

# MAUVAIS : analyser ls
for f in $(ls /var/log/*.log); do ... done
nb=$(ls -l /tmp/ | grep "^-" | wc -l)

# CORRECT : globs
for f in /var/log/*.log; do ... done
nb=0
for f in /tmp/*; do [ -f "$f" ] && (( nb++ )); done

# CORRECT : find
find /tmp -maxdepth 1 -type f | wc -l

# Cas particulier : fichiers avec certains attributs
find /var/log -name "*.log" -newer /tmp/reference -size +1M

La boucle for f in $(cat liste) : le piège#

Une autre erreur courante est d’utiliser for f in $(cat liste.txt) pour parcourir les lignes d’un fichier.

# MAUVAIS : word splitting sur les espaces, globbing, lignes vides ignorées
for ligne in $(cat fichier.txt); do
    echo "Traitement : $ligne"
done

# Si une ligne vaut "rapport final.pdf", elle sera traitée comme deux entrées.
# Si une ligne vaut "*.txt", elle sera étendue en les fichiers .txt.

# CORRECT : while read avec redirection d'entrée
while IFS= read -r ligne; do
    echo "Traitement : $ligne"
done < fichier.txt

# CORRECT : while read depuis un pipe
grep "actif" users.txt | while IFS= read -r ligne; do
    echo "Utilisateur actif : $ligne"
done

# Explication des options :
# IFS= : ne pas supprimer les espaces de début et de fin de ligne
# -r   : ne pas interpréter les antislashes comme des échappements

Exemple 35 (Lire un fichier ligne par ligne)

# Lire un fichier de configuration clé=valeur
while IFS='=' read -r cle valeur; do
    # Ignorer les commentaires et les lignes vides
    [[ "$cle" =~ ^[[:space:]]*# ]] && continue
    [[ -z "$cle" ]] && continue
    echo "Clé : $cle, Valeur : $valeur"
done < config.ini

# Lire un CSV (en supposant pas de guillemets dans les champs)
while IFS=',' read -r nom prenom email role; do
    echo "Utilisateur : $prenom $nom <$email> [$role]"
done < utilisateurs.csv

ShellCheck : l’outil d’analyse statique#

ShellCheck est un outil d’analyse statique pour les scripts shell qui détecte automatiquement une grande variété de bugs, pièges et problèmes de style. Il est comparable à pylint pour Python ou eslint pour JavaScript.

Définition 66 (ShellCheck)

ShellCheck est un analyseur statique open source pour les scripts shell (Bash, sh, dash, ksh). Il détecte les erreurs de quoting, les variables non définies, les constructions dépréciées, les pièges de portabilité et de nombreux autres problèmes. Il est disponible en ligne sur shellcheck.net et en ligne de commande.

Installation#

# Debian/Ubuntu
sudo apt install shellcheck

# Fedora/RHEL
sudo dnf install shellcheck

# macOS avec Homebrew
brew install shellcheck

# Avec cabal (Haskell)
cabal install ShellCheck

# Vérifier l'installation
shellcheck --version

Utilisation en ligne de commande#

# Analyser un script
shellcheck mon_script.sh

# Analyser plusieurs scripts
shellcheck *.sh scripts/**/*.sh

# Choisir le niveau de sévérité minimum affiché
shellcheck --severity=warning mon_script.sh

# Exclure certains codes d'erreur
shellcheck --exclude=SC2086,SC2046 mon_script.sh

# Format de sortie JSON (pour l'intégration dans des outils)
shellcheck --format=json mon_script.sh

# Format GCC (pour les éditeurs qui reconnaissent ce format)
shellcheck --format=gcc mon_script.sh

Comprendre les codes ShellCheck#

Chaque diagnostic ShellCheck est identifié par un code SC suivi d’un numéro :

# Exemple de sortie ShellCheck
$ shellcheck exemple.sh

In exemple.sh line 5:
for f in $(ls *.txt); do
         ^---------^ SC2045: Iterating over ls output is fragile.
                              Use globs.

In exemple.sh line 8:
cp $fichier /dest/
   ^------^ SC2086: Double quote to prevent globbing and word splitting.

Directives inline#

ShellCheck permet d’inhiber un avertissement spécifique sur une ligne ou une région :

# Inhiber un avertissement pour la ligne suivante
# shellcheck disable=SC2086
echo $variable_intentionnellement_sans_guillemets

# Inhiber pour une région
# shellcheck disable=SC2046,SC2086
commande_complexe_avec_raison_valide

# Inhiber pour tout le fichier (à éviter)
# shellcheck disable=SC2086

# Meilleure pratique : ajouter un commentaire expliquant pourquoi
variable="valeur avec espaces"
# shellcheck disable=SC2086
# Raison : la commande attend plusieurs arguments séparés
old_cmd $variable

Intégration dans l’éditeur#

ShellCheck s’intègre dans la plupart des éditeurs modernes :

# VS Code : extension "ShellCheck" (timonwong.shellcheck)
# Neovim : via ALE ou null-ls avec shellcheck comme linter
# Vim : via syntastic ou ALE
# Emacs : via flycheck avec sh-shellcheck

# Configuration recommandée pour VS Code (.vscode/settings.json)
{
    "shellcheck.enable": true,
    "shellcheck.run": "onType",
    "shellcheck.executablePath": "/usr/bin/shellcheck",
    "shellcheck.customArgs": ["--external-sources"]
}

Intégration dans la CI#

ShellCheck s’intègre naturellement dans une pipeline d’intégration continue :

# GitHub Actions
name: ShellCheck
on: [push, pull_request]
jobs:
  shellcheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Vérification ShellCheck
        uses: ludeeus/action-shellcheck@master
        with:
          severity: warning
          scandir: './scripts'
# Hook pre-commit Git (.git/hooks/pre-commit)
#!/usr/bin/env bash
set -euo pipefail

# Vérifier les scripts shell modifiés
scripts_modifies=$(git diff --cached --name-only | grep -E '\.(sh|bash)$' || true)
if [ -n "$scripts_modifies" ]; then
    echo "Vérification ShellCheck..."
    shellcheck $scripts_modifies
fi

# Vérification syntaxique
bash_scripts=$(git diff --cached --name-only | grep -E '\.(sh|bash)$' || true)
for script in $bash_scripts; do
    bash -n "$script"
done

Style et lisibilité#

Définition 67 (Conventions de nommage)

Les conventions de nommage les plus répandues dans la communauté Bash :

  • MAJUSCULES pour les constantes et les variables d’environnement exportées : readonly MAX_TENTATIVES=3, export PATH.

  • minuscules_avec_underscores pour les variables locales et les noms de fonctions : local compteur=0, calculer_moyenne().

  • _prefixe parfois utilisé pour les variables internes à une bibliothèque ou module.

#!/usr/bin/env bash
# Style recommandé

# --- Constantes ---
readonly VERSION="2.1.0"
readonly REPERTOIRE_LOG="/var/log/mon-app"
readonly TIMEOUT_SECONDES=30

# --- Variables globales ---
verbose=false
mode_simulation=false

# --- Fonctions (snake_case, verbe_nom) ---
verifier_dependances() {
    local -a deps=("curl" "jq" "rsync")
    local dep
    for dep in "${deps[@]}"; do
        if ! command -v "$dep" &>/dev/null; then
            echo "Dépendance manquante : $dep" >&2
            return 1
        fi
    done
}

calculer_checksum_fichier() {
    local chemin="$1"
    sha256sum "$chemin" | awk '{ print $1 }'
}

# --- Indentation : 2 ou 4 espaces (cohérence dans tout le projet) ---
traiter_utilisateurs() {
    local fichier="$1"
    local compteur=0

    while IFS=',' read -r id nom email; do
        [[ "$id" == "id" ]] && continue   # Ignorer l'en-tête

        if [[ -z "$email" ]]; then
            echo "Avertissement : pas d'email pour l'utilisateur $id" >&2
            continue
        fi

        (( compteur++ ))
        $verbose && echo "Traitement de $nom ($email)"
    done < "$fichier"

    echo "Traitement terminé : $compteur utilisateurs traités."
}

Commentaires#

#!/usr/bin/env bash
# ===========================================================================
# deploy.sh — Script de déploiement de l'application
#
# Ce script effectue les étapes suivantes :
#   1. Vérification des prérequis
#   2. Récupération du code source
#   3. Construction de l'image Docker
#   4. Déploiement sur le cluster
#
# Usage    : ./deploy.sh [--env production|staging] [--tag VERSION]
# Prérequis: docker, kubectl, jq
# Auteur   : Équipe Infrastructure
# ===========================================================================

# Commentaire expliquant le POURQUOI (pas le comment)
# On utilise mktemp plutôt qu'un chemin fixe pour éviter les conditions de course
# dans un environnement où plusieurs instances du script peuvent tourner simultanément.
tmpdir=$(mktemp -d)

# TODO: Ajouter une vérification de l'espace disque disponible avant le build
taille_image=$(docker image inspect "$IMAGE" --format '{{.Size}}' 2>/dev/null || echo 0)

Sécurité des scripts#

Injection de commandes#

Le risque d’injection de commandes survient lorsqu’une entrée extérieure (argument de l’utilisateur, valeur d’une variable d’environnement, sortie d’une commande) est utilisée de façon non sécurisée dans une commande shell.

# DANGEREUX : injection possible si NOM_UTILISATEUR contient "; rm -rf /"
eval "ls /home/$NOM_UTILISATEUR"   # JAMAIS faire ça

# DANGEREUX : avec shell=True en Python ou eval en Bash
commande="cat $fichier_utilisateur"
eval "$commande"                   # DANGEREUX

# CORRECT : construire la commande comme une liste d'arguments
ls "/home/$NOM_UTILISATEUR"        # Les guillemets empêchent l'injection

```{prf:definition} L’instruction eval : à éviter :label: definition-20-05 eval exécute son argument comme une commande shell après une double expansion. C’est l’un des rares cas où une commande Bash peut être véritablement dangereuse : si son argument contient des données non contrôlées, eval peut exécuter du code arbitraire avec les privilèges du script. Dans presque tous les cas, eval peut être remplacé par des tableaux Bash ou des variables de référence indirecte (${!variable}).


```bash
# Cas où eval est parfois tenté (et son alternative sûre)

# Mauvais : eval pour construire une commande dynamique
eval "commande_$action --flag"

# Correct : utiliser un tableau associatif ou un case
case "$action" in
    start)   demarrer_service ;;
    stop)    arreter_service ;;
    restart) redemarrer_service ;;
    *)       echo "Action inconnue : $action" >&2; exit 1 ;;
esac

Gestion des secrets#

Remarque 57

Règles de sécurité pour les secrets dans les scripts :

  1. Ne jamais mettre un mot de passe ou un token dans un script, même dans un commentaire. Les scripts sont souvent versionnés dans Git, partagés, et lisibles par d’autres.

  2. Utiliser les variables d’environnement pour passer les secrets au script, et les définir dans des fichiers .env exclus du versionnage (.gitignore).

  3. Utiliser un gestionnaire de secrets : Vault (HashiCorp), AWS Secrets Manager, pass, ou les secrets de l’OS (keyring GNOME, macOS Keychain).

  4. Ne pas logger les secrets : set -x affiche les valeurs des variables ; désactiver la trace pour les sections manipulant des secrets.

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

# Lire le token depuis une variable d'environnement (jamais en dur)
API_TOKEN="${API_TOKEN:?La variable API_TOKEN doit être définie}"

# Ou depuis un fichier de secrets (permissions 600)
if [ -f ~/.config/mon-app/token ]; then
    API_TOKEN=$(cat ~/.config/mon-app/token)
fi

# Désactiver set -x pour les sections sensibles
set +x
curl -s -H "Authorization: Bearer $API_TOKEN" https://api.exemple.fr/data
set -x  # Réactiver si nécessaire

umask : permissions par défaut#

```{prf:definition} umask :label: definition-20-06 umask définit un masque de permissions qui est soustrait des permissions par défaut lors de la création de fichiers et répertoires. Un umask de 022 signifie que les fichiers sont créés avec les permissions 644 (rw-r–r–) et les répertoires avec 755 (rwxr-xr-x). Pour les scripts qui créent des fichiers contenant des données sensibles, un umask plus restrictif est conseillé.


```bash
#!/usr/bin/env bash

# Définir un umask restrictif pour ce script
umask 077   # Fichiers créés : 600 (rw-------)

# Les fichiers temporaires ne seront lisibles que par root/l'utilisateur courant
TMPFICHIER=$(mktemp)   # Créé avec 600 grâce au umask 077
echo "Données sensibles" > "$TMPFICHIER"

Hide code cell source

fig, ax = plt.subplots(figsize=(14, 9))
ax.set_xlim(-0.5, 14)
ax.set_ylim(-0.5, 10)
ax.axis('off')
ax.set_title('Top 10 des erreurs ShellCheck les plus fréquentes',
             fontsize=14, fontweight='bold', pad=20)

# Données : code, description, fréquence relative, catégorie
erreurs = [
    ('SC2086', 'Variable sans guillemets\n$var au lieu de "$var"', 95, 'Quoting'),
    ('SC2006', 'Backticks dépréciés\n`cmd` au lieu de $(cmd)', 82, 'Style'),
    ('SC2046', 'Expression sans guillemets\n$(cmd) sans "$(...)"', 78, 'Quoting'),
    ('SC2045', 'Itération sur la sortie de ls\nfor f in $(ls ...)', 70, 'Correctness'),
    ('SC2035', 'Glob commençant par un tiret\n-f* peut être une option', 60, 'Correctness'),
    ('SC2164', 'cd sans vérification\ncd /chemin sans || exit', 55, 'Robustesse'),
    ('SC2148', 'Shebang manquant\nscript sans #!/bin/bash', 50, 'Portabilité'),
    ('SC2034', 'Variable définie mais non utilisée\nlocal var=..., jamais lue', 45, 'Style'),
    ('SC2059', 'Percent dans printf\nprintf "$format" au lieu de "%s"', 40, 'Sécurité'),
    ('SC2155', 'Déclaration et affectation séparées\nlocal v=$(cmd) masque le code', 35, 'Robustesse'),
]

couleurs_cat = {
    'Quoting': '#e74c3c',
    'Style': '#3498db',
    'Correctness': '#e67e22',
    'Robustesse': '#27ae60',
    'Portabilité': '#9b59b6',
    'Sécurité': '#c0392b',
}

y_positions = np.arange(len(erreurs) - 1, -1, -1) * 0.92 + 0.5

for i, (code, desc, freq, cat) in enumerate(erreurs):
    y = y_positions[i]
    couleur = couleurs_cat[cat]

    # Barre de fréquence
    largeur_barre = freq / 100 * 7.0
    barre = patches.FancyBboxPatch((3.5, y - 0.30), largeur_barre, 0.60,
                                   boxstyle='round,pad=0.05', linewidth=1,
                                   edgecolor=couleur, facecolor=couleur, alpha=0.75)
    ax.add_patch(barre)

    # Code ShellCheck
    ax.text(3.4, y, code, ha='right', va='center',
            fontsize=9, fontweight='bold', color='#2c3e50',
            fontfamily='monospace')

    # Pourcentage
    ax.text(3.5 + largeur_barre + 0.15, y, f'{freq}%',
            ha='left', va='center', fontsize=8.5, color=couleur,
            fontweight='bold')

    # Description (à droite)
    ax.text(11.5, y, desc, ha='left', va='center',
            fontsize=7.8, color='#444', multialignment='left')

    # Badge catégorie
    b_cat = patches.FancyBboxPatch((10.3, y - 0.22), 0.9, 0.44,
                                   boxstyle='round,pad=0.08', linewidth=1,
                                   edgecolor=couleur, facecolor=couleur, alpha=0.15)
    ax.add_patch(b_cat)
    ax.text(10.75, y, cat[:5], ha='center', va='center',
            fontsize=7, color=couleur, fontweight='bold')

# En-têtes
ax.text(3.4, 9.6, 'Code', ha='right', va='center',
        fontsize=10, fontweight='bold', color='#2c3e50')
ax.text(7.0, 9.6, 'Fréquence relative', ha='center', va='center',
        fontsize=10, fontweight='bold', color='#2c3e50')
ax.text(11.5, 9.6, 'Description', ha='left', va='center',
        fontsize=10, fontweight='bold', color='#2c3e50')
ax.axhline(9.3, color='#bbb', lw=1, xmin=0.2, xmax=0.98)

# Axe des abscisses (pourcentages)
for pct in [0, 25, 50, 75, 100]:
    x = 3.5 + pct / 100 * 7.0
    ax.axvline(x, color='#ddd', lw=0.7, alpha=0.5,
               ymin=0.04, ymax=0.94)
    ax.text(x, 0.1, f'{pct}%', ha='center', va='bottom',
            fontsize=7.5, color='#888')

plt.tight_layout()
plt.show()
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[2], line 83
     78     ax.axvline(x, color='#ddd', lw=0.7, alpha=0.5,
     79                ymin=0.04, ymax=0.94)
     80     ax.text(x, 0.1, f'{pct}%', ha='center', va='bottom',
     81             fontsize=7.5, color='#888')
---> 83 plt.tight_layout()
     84 plt.show()

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/pyplot.py:2843, in tight_layout(pad, h_pad, w_pad, rect)
   2835 @_copy_docstring_and_deprecators(Figure.tight_layout)
   2836 def tight_layout(
   2837     *,
   (...)   2841     rect: tuple[float, float, float, float] | None = None,
   2842 ) -> None:
-> 2843     gcf().tight_layout(pad=pad, h_pad=h_pad, w_pad=w_pad, rect=rect)

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/figure.py:3640, in Figure.tight_layout(self, pad, h_pad, w_pad, rect)
   3638 previous_engine = self.get_layout_engine()
   3639 self.set_layout_engine(engine)
-> 3640 engine.execute(self)
   3641 if previous_engine is not None and not isinstance(
   3642     previous_engine, (TightLayoutEngine, PlaceHolderLayoutEngine)
   3643 ):
   3644     _api.warn_external('The figure layout has changed to tight')

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/layout_engine.py:188, in TightLayoutEngine.execute(self, fig)
    186 renderer = fig._get_renderer()
    187 with getattr(renderer, "_draw_disabled", nullcontext)():
--> 188     kwargs = get_tight_layout_figure(
    189         fig, fig.axes, get_subplotspec_list(fig.axes), renderer,
    190         pad=info['pad'], h_pad=info['h_pad'], w_pad=info['w_pad'],
    191         rect=info['rect'])
    192 if kwargs:
    193     fig.subplots_adjust(**kwargs)

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/_tight_layout.py:266, in get_tight_layout_figure(fig, axes_list, subplotspec_list, renderer, pad, h_pad, w_pad, rect)
    261         return {}
    262     span_pairs.append((
    263         slice(ss.rowspan.start * div_row, ss.rowspan.stop * div_row),
    264         slice(ss.colspan.start * div_col, ss.colspan.stop * div_col)))
--> 266 kwargs = _auto_adjust_subplotpars(fig, renderer,
    267                                   shape=(max_nrows, max_ncols),
    268                                   span_pairs=span_pairs,
    269                                   subplot_list=subplot_list,
    270                                   ax_bbox_list=ax_bbox_list,
    271                                   pad=pad, h_pad=h_pad, w_pad=w_pad)
    273 # kwargs can be none if tight_layout fails...
    274 if rect is not None and kwargs is not None:
    275     # if rect is given, the whole subplots area (including
    276     # labels) will fit into the rect instead of the
   (...)    280     # auto_adjust_subplotpars twice, where the second run
    281     # with adjusted rect parameters.

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/_tight_layout.py:82, in _auto_adjust_subplotpars(fig, renderer, shape, span_pairs, subplot_list, ax_bbox_list, pad, h_pad, w_pad, rect)
     80 for ax in subplots:
     81     if ax.get_visible():
---> 82         bb += [martist._get_tightbbox_for_layout_only(ax, renderer)]
     84 tight_bbox_raw = Bbox.union(bb)
     85 tight_bbox = fig.transFigure.inverted().transform_bbox(tight_bbox_raw)

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/artist.py:1402, in _get_tightbbox_for_layout_only(obj, *args, **kwargs)
   1396 """
   1397 Matplotlib's `.Axes.get_tightbbox` and `.Axis.get_tightbbox` support a
   1398 *for_layout_only* kwarg; this helper tries to use the kwarg but skips it
   1399 when encountering third-party subclasses that do not support it.
   1400 """
   1401 try:
-> 1402     return obj.get_tightbbox(*args, **{**kwargs, "for_layout_only": True})
   1403 except TypeError:
   1404     return obj.get_tightbbox(*args, **kwargs)

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/axes/_base.py:4587, in _AxesBase.get_tightbbox(self, renderer, call_axes_locator, bbox_extra_artists, for_layout_only)
   4584     bbox_artists = self.get_default_bbox_extra_artists()
   4586 for a in bbox_artists:
-> 4587     bbox = a.get_tightbbox(renderer)
   4588     if (bbox is not None
   4589             and 0 < bbox.width < np.inf
   4590             and 0 < bbox.height < np.inf):
   4591         bb.append(bbox)

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/artist.py:364, in Artist.get_tightbbox(self, renderer)
    348 def get_tightbbox(self, renderer=None):
    349     """
    350     Like `.Artist.get_window_extent`, but includes any clipping.
    351 
   (...)    362         Returns None if clipping results in no intersection.
    363     """
--> 364     bbox = self.get_window_extent(renderer)
    365     if self.get_clip_on():
    366         clip_box = self.get_clip_box()

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/text.py:969, in Text.get_window_extent(self, renderer, dpi)
    964     raise RuntimeError(
    965         "Cannot get window extent of text w/o renderer. You likely "
    966         "want to call 'figure.draw_without_rendering()' first.")
    968 with cbook._setattr_cm(fig, dpi=dpi):
--> 969     bbox, info, descent = self._get_layout(self._renderer)
    970     x, y = self.get_unitless_position()
    971     x, y = self.get_transform().transform((x, y))

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/text.py:382, in Text._get_layout(self, renderer)
    380 clean_line, ismath = self._preprocess_math(line)
    381 if clean_line:
--> 382     w, h, d = _get_text_metrics_with_cache(
    383         renderer, clean_line, self._fontproperties,
    384         ismath=ismath, dpi=self.get_figure(root=True).dpi)
    385 else:
    386     w = h = d = 0

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/text.py:69, in _get_text_metrics_with_cache(renderer, text, fontprop, ismath, dpi)
     66 """Call ``renderer.get_text_width_height_descent``, caching the results."""
     67 # Cached based on a copy of fontprop so that later in-place mutations of
     68 # the passed-in argument do not mess up the cache.
---> 69 return _get_text_metrics_with_cache_impl(
     70     weakref.ref(renderer), text, fontprop.copy(), ismath, dpi)

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/text.py:77, in _get_text_metrics_with_cache_impl(renderer_ref, text, fontprop, ismath, dpi)
     73 @functools.lru_cache(4096)
     74 def _get_text_metrics_with_cache_impl(
     75         renderer_ref, text, fontprop, ismath, dpi):
     76     # dpi is unused, but participates in cache invalidation (via the renderer).
---> 77     return renderer_ref().get_text_width_height_descent(text, fontprop, ismath)

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/backends/backend_agg.py:215, in RendererAgg.get_text_width_height_descent(self, s, prop, ismath)
    211     return super().get_text_width_height_descent(s, prop, ismath)
    213 if ismath:
    214     ox, oy, width, height, descent, font_image = \
--> 215         self.mathtext_parser.parse(s, self.dpi, prop)
    216     return width, height, descent
    218 font = self._prepare_font(prop)

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/mathtext.py:86, in MathTextParser.parse(self, s, dpi, prop, antialiased)
     81 from matplotlib.backends import backend_agg
     82 load_glyph_flags = {
     83     "vector": LoadFlags.NO_HINTING,
     84     "raster": backend_agg.get_hinting_flag(),
     85 }[self._output_type]
---> 86 return self._parse_cached(s, dpi, prop, antialiased, load_glyph_flags)

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/mathtext.py:100, in MathTextParser._parse_cached(self, s, dpi, prop, antialiased, load_glyph_flags)
     97 if self._parser is None:  # Cache the parser globally.
     98     self.__class__._parser = _mathtext.Parser()
--> 100 box = self._parser.parse(s, fontset, fontsize, dpi)
    101 output = _mathtext.ship(box)
    102 if self._output_type == "vector":

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/_mathtext.py:2167, in Parser.parse(self, s, fonts_object, fontsize, dpi)
   2164     result = self._expression.parse_string(s)
   2165 except ParseBaseException as err:
   2166     # explain becomes a plain method on pyparsing 3 (err.explain(0)).
-> 2167     raise ValueError("\n" + ParseException.explain(err, 0)) from None
   2168 self._state_stack = []
   2169 self._in_subscript_or_superscript = False

ValueError: 
$var au lieu de "$var"
^
ParseException: Expected end of text, found '$'  (at char 0), (line:1, col:1)
Error in callback <function _draw_all_if_interactive at 0x7fc1127674c0> (for post_execute), with arguments args (),kwargs {}:
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/pyplot.py:278, in _draw_all_if_interactive()
    276 def _draw_all_if_interactive() -> None:
    277     if matplotlib.is_interactive():
--> 278         draw_all()

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/_pylab_helpers.py:131, in Gcf.draw_all(cls, force)
    129 for manager in cls.get_all_fig_managers():
    130     if force or manager.canvas.figure.stale:
--> 131         manager.canvas.draw_idle()

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/backend_bases.py:1893, in FigureCanvasBase.draw_idle(self, *args, **kwargs)
   1891 if not self._is_idle_drawing:
   1892     with self._idle_draw_cntx():
-> 1893         self.draw(*args, **kwargs)

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/backends/backend_agg.py:382, in FigureCanvasAgg.draw(self)
    379 # Acquire a lock on the shared font cache.
    380 with (self.toolbar._wait_cursor_for_draw_cm() if self.toolbar
    381       else nullcontext()):
--> 382     self.figure.draw(self.renderer)
    383     # A GUI class may be need to update a window using this draw, so
    384     # don't forget to call the superclass.
    385     super().draw()

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/artist.py:94, in _finalize_rasterization.<locals>.draw_wrapper(artist, renderer, *args, **kwargs)
     92 @wraps(draw)
     93 def draw_wrapper(artist, renderer, *args, **kwargs):
---> 94     result = draw(artist, renderer, *args, **kwargs)
     95     if renderer._rasterizing:
     96         renderer.stop_rasterizing()

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/artist.py:71, in allow_rasterization.<locals>.draw_wrapper(artist, renderer)
     68     if artist.get_agg_filter() is not None:
     69         renderer.start_filter()
---> 71     return draw(artist, renderer)
     72 finally:
     73     if artist.get_agg_filter() is not None:

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/figure.py:3257, in Figure.draw(self, renderer)
   3254             # ValueError can occur when resizing a window.
   3256     self.patch.draw(renderer)
-> 3257     mimage._draw_list_compositing_images(
   3258         renderer, self, artists, self.suppressComposite)
   3260     renderer.close_group('figure')
   3261 finally:

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/image.py:134, in _draw_list_compositing_images(renderer, parent, artists, suppress_composite)
    132 if not_composite or not has_images:
    133     for a in artists:
--> 134         a.draw(renderer)
    135 else:
    136     # Composite any adjacent images together
    137     image_group = []

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/artist.py:71, in allow_rasterization.<locals>.draw_wrapper(artist, renderer)
     68     if artist.get_agg_filter() is not None:
     69         renderer.start_filter()
---> 71     return draw(artist, renderer)
     72 finally:
     73     if artist.get_agg_filter() is not None:

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/axes/_base.py:3226, in _AxesBase.draw(self, renderer)
   3223 if artists_rasterized:
   3224     _draw_rasterized(self.get_figure(root=True), artists_rasterized, renderer)
-> 3226 mimage._draw_list_compositing_images(
   3227     renderer, self, artists, self.get_figure(root=True).suppressComposite)
   3229 renderer.close_group('axes')
   3230 self.stale = False

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/image.py:134, in _draw_list_compositing_images(renderer, parent, artists, suppress_composite)
    132 if not_composite or not has_images:
    133     for a in artists:
--> 134         a.draw(renderer)
    135 else:
    136     # Composite any adjacent images together
    137     image_group = []

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/artist.py:71, in allow_rasterization.<locals>.draw_wrapper(artist, renderer)
     68     if artist.get_agg_filter() is not None:
     69         renderer.start_filter()
---> 71     return draw(artist, renderer)
     72 finally:
     73     if artist.get_agg_filter() is not None:

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/text.py:752, in Text.draw(self, renderer)
    749 renderer.open_group('text', self.get_gid())
    751 with self._cm_set(text=self._get_wrapped_text()):
--> 752     bbox, info, descent = self._get_layout(renderer)
    753     trans = self.get_transform()
    755     # don't use self.get_position here, which refers to text
    756     # position in Text:

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/text.py:382, in Text._get_layout(self, renderer)
    380 clean_line, ismath = self._preprocess_math(line)
    381 if clean_line:
--> 382     w, h, d = _get_text_metrics_with_cache(
    383         renderer, clean_line, self._fontproperties,
    384         ismath=ismath, dpi=self.get_figure(root=True).dpi)
    385 else:
    386     w = h = d = 0

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/text.py:69, in _get_text_metrics_with_cache(renderer, text, fontprop, ismath, dpi)
     66 """Call ``renderer.get_text_width_height_descent``, caching the results."""
     67 # Cached based on a copy of fontprop so that later in-place mutations of
     68 # the passed-in argument do not mess up the cache.
---> 69 return _get_text_metrics_with_cache_impl(
     70     weakref.ref(renderer), text, fontprop.copy(), ismath, dpi)

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/text.py:77, in _get_text_metrics_with_cache_impl(renderer_ref, text, fontprop, ismath, dpi)
     73 @functools.lru_cache(4096)
     74 def _get_text_metrics_with_cache_impl(
     75         renderer_ref, text, fontprop, ismath, dpi):
     76     # dpi is unused, but participates in cache invalidation (via the renderer).
---> 77     return renderer_ref().get_text_width_height_descent(text, fontprop, ismath)

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/backends/backend_agg.py:215, in RendererAgg.get_text_width_height_descent(self, s, prop, ismath)
    211     return super().get_text_width_height_descent(s, prop, ismath)
    213 if ismath:
    214     ox, oy, width, height, descent, font_image = \
--> 215         self.mathtext_parser.parse(s, self.dpi, prop)
    216     return width, height, descent
    218 font = self._prepare_font(prop)

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/mathtext.py:86, in MathTextParser.parse(self, s, dpi, prop, antialiased)
     81 from matplotlib.backends import backend_agg
     82 load_glyph_flags = {
     83     "vector": LoadFlags.NO_HINTING,
     84     "raster": backend_agg.get_hinting_flag(),
     85 }[self._output_type]
---> 86 return self._parse_cached(s, dpi, prop, antialiased, load_glyph_flags)

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/mathtext.py:100, in MathTextParser._parse_cached(self, s, dpi, prop, antialiased, load_glyph_flags)
     97 if self._parser is None:  # Cache the parser globally.
     98     self.__class__._parser = _mathtext.Parser()
--> 100 box = self._parser.parse(s, fontset, fontsize, dpi)
    101 output = _mathtext.ship(box)
    102 if self._output_type == "vector":

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/_mathtext.py:2167, in Parser.parse(self, s, fonts_object, fontsize, dpi)
   2164     result = self._expression.parse_string(s)
   2165 except ParseBaseException as err:
   2166     # explain becomes a plain method on pyparsing 3 (err.explain(0)).
-> 2167     raise ValueError("\n" + ParseException.explain(err, 0)) from None
   2168 self._state_stack = []
   2169 self._in_subscript_or_superscript = False

ValueError: 
$var au lieu de "$var"
^
ParseException: Expected end of text, found '$'  (at char 0), (line:1, col:1)
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/IPython/core/formatters.py:402, in BaseFormatter.__call__(self, obj)
    400     pass
    401 else:
--> 402     return printer(obj)
    403 # Finally look for special method names
    404 method = get_real_method(obj, self.print_method)

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/IPython/core/pylabtools.py:170, in print_figure(fig, fmt, bbox_inches, base64, **kwargs)
    167     from matplotlib.backend_bases import FigureCanvasBase
    168     FigureCanvasBase(fig)
--> 170 fig.canvas.print_figure(bytes_io, **kw)
    171 data = bytes_io.getvalue()
    172 if fmt == 'svg':

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/backend_bases.py:2157, in FigureCanvasBase.print_figure(self, filename, dpi, facecolor, edgecolor, orientation, format, bbox_inches, pad_inches, bbox_extra_artists, backend, **kwargs)
   2154     # we do this instead of `self.figure.draw_without_rendering`
   2155     # so that we can inject the orientation
   2156     with getattr(renderer, "_draw_disabled", nullcontext)():
-> 2157         self.figure.draw(renderer)
   2158 if bbox_inches:
   2159     if bbox_inches == "tight":

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/artist.py:94, in _finalize_rasterization.<locals>.draw_wrapper(artist, renderer, *args, **kwargs)
     92 @wraps(draw)
     93 def draw_wrapper(artist, renderer, *args, **kwargs):
---> 94     result = draw(artist, renderer, *args, **kwargs)
     95     if renderer._rasterizing:
     96         renderer.stop_rasterizing()

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/artist.py:71, in allow_rasterization.<locals>.draw_wrapper(artist, renderer)
     68     if artist.get_agg_filter() is not None:
     69         renderer.start_filter()
---> 71     return draw(artist, renderer)
     72 finally:
     73     if artist.get_agg_filter() is not None:

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/figure.py:3257, in Figure.draw(self, renderer)
   3254             # ValueError can occur when resizing a window.
   3256     self.patch.draw(renderer)
-> 3257     mimage._draw_list_compositing_images(
   3258         renderer, self, artists, self.suppressComposite)
   3260     renderer.close_group('figure')
   3261 finally:

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/image.py:134, in _draw_list_compositing_images(renderer, parent, artists, suppress_composite)
    132 if not_composite or not has_images:
    133     for a in artists:
--> 134         a.draw(renderer)
    135 else:
    136     # Composite any adjacent images together
    137     image_group = []

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/artist.py:71, in allow_rasterization.<locals>.draw_wrapper(artist, renderer)
     68     if artist.get_agg_filter() is not None:
     69         renderer.start_filter()
---> 71     return draw(artist, renderer)
     72 finally:
     73     if artist.get_agg_filter() is not None:

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/axes/_base.py:3226, in _AxesBase.draw(self, renderer)
   3223 if artists_rasterized:
   3224     _draw_rasterized(self.get_figure(root=True), artists_rasterized, renderer)
-> 3226 mimage._draw_list_compositing_images(
   3227     renderer, self, artists, self.get_figure(root=True).suppressComposite)
   3229 renderer.close_group('axes')
   3230 self.stale = False

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/image.py:134, in _draw_list_compositing_images(renderer, parent, artists, suppress_composite)
    132 if not_composite or not has_images:
    133     for a in artists:
--> 134         a.draw(renderer)
    135 else:
    136     # Composite any adjacent images together
    137     image_group = []

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/artist.py:71, in allow_rasterization.<locals>.draw_wrapper(artist, renderer)
     68     if artist.get_agg_filter() is not None:
     69         renderer.start_filter()
---> 71     return draw(artist, renderer)
     72 finally:
     73     if artist.get_agg_filter() is not None:

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/text.py:752, in Text.draw(self, renderer)
    749 renderer.open_group('text', self.get_gid())
    751 with self._cm_set(text=self._get_wrapped_text()):
--> 752     bbox, info, descent = self._get_layout(renderer)
    753     trans = self.get_transform()
    755     # don't use self.get_position here, which refers to text
    756     # position in Text:

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/text.py:382, in Text._get_layout(self, renderer)
    380 clean_line, ismath = self._preprocess_math(line)
    381 if clean_line:
--> 382     w, h, d = _get_text_metrics_with_cache(
    383         renderer, clean_line, self._fontproperties,
    384         ismath=ismath, dpi=self.get_figure(root=True).dpi)
    385 else:
    386     w = h = d = 0

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/text.py:69, in _get_text_metrics_with_cache(renderer, text, fontprop, ismath, dpi)
     66 """Call ``renderer.get_text_width_height_descent``, caching the results."""
     67 # Cached based on a copy of fontprop so that later in-place mutations of
     68 # the passed-in argument do not mess up the cache.
---> 69 return _get_text_metrics_with_cache_impl(
     70     weakref.ref(renderer), text, fontprop.copy(), ismath, dpi)

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/text.py:77, in _get_text_metrics_with_cache_impl(renderer_ref, text, fontprop, ismath, dpi)
     73 @functools.lru_cache(4096)
     74 def _get_text_metrics_with_cache_impl(
     75         renderer_ref, text, fontprop, ismath, dpi):
     76     # dpi is unused, but participates in cache invalidation (via the renderer).
---> 77     return renderer_ref().get_text_width_height_descent(text, fontprop, ismath)

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/backends/backend_agg.py:215, in RendererAgg.get_text_width_height_descent(self, s, prop, ismath)
    211     return super().get_text_width_height_descent(s, prop, ismath)
    213 if ismath:
    214     ox, oy, width, height, descent, font_image = \
--> 215         self.mathtext_parser.parse(s, self.dpi, prop)
    216     return width, height, descent
    218 font = self._prepare_font(prop)

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/mathtext.py:86, in MathTextParser.parse(self, s, dpi, prop, antialiased)
     81 from matplotlib.backends import backend_agg
     82 load_glyph_flags = {
     83     "vector": LoadFlags.NO_HINTING,
     84     "raster": backend_agg.get_hinting_flag(),
     85 }[self._output_type]
---> 86 return self._parse_cached(s, dpi, prop, antialiased, load_glyph_flags)

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/mathtext.py:100, in MathTextParser._parse_cached(self, s, dpi, prop, antialiased, load_glyph_flags)
     97 if self._parser is None:  # Cache the parser globally.
     98     self.__class__._parser = _mathtext.Parser()
--> 100 box = self._parser.parse(s, fontset, fontsize, dpi)
    101 output = _mathtext.ship(box)
    102 if self._output_type == "vector":

File ~/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/matplotlib/_mathtext.py:2167, in Parser.parse(self, s, fonts_object, fontsize, dpi)
   2164     result = self._expression.parse_string(s)
   2165 except ParseBaseException as err:
   2166     # explain becomes a plain method on pyparsing 3 (err.explain(0)).
-> 2167     raise ValueError("\n" + ParseException.explain(err, 0)) from None
   2168 self._state_stack = []
   2169 self._in_subscript_or_superscript = False

ValueError: 
$var au lieu de "$var"
^
ParseException: Expected end of text, found '$'  (at char 0), (line:1, col:1)
<Figure size 1400x900 with 1 Axes>

Résumé et perspectives#

Ce dernier chapitre a rassemblé les bonnes pratiques essentielles pour écrire du Bash fiable et maintenable :

  • La règle d’or du quoting"$variable" et non $variable — prévient le word splitting et le globbing non voulus, sources de la majorité des bugs Bash.

  • Les noms de fichiers avec espaces nécessitent des globs directs (for f in *.txt) ou find -print0 avec read -d '', jamais ls parsé.

  • [[ ]] est préférable à [ ] pour sa robustesse : pas de word splitting interne, opérateurs logiques naturels, pattern matching et regex.

  • La boucle while IFS= read -r ligne; do ... done < fichier est le seul moyen correct de lire un fichier ligne par ligne.

  • ShellCheck automatise la détection des pièges : son installation et son intégration dans l’éditeur et la CI sont vivement recommandées. Les codes les plus fréquents — SC2086 (quoting), SC2006 (backticks), SC2045 (ls) — correspondent précisément aux pièges décrits dans ce chapitre.

  • Les conventions de nommage (MAJUSCULES pour les constantes, minuscules pour les variables locales), l’indentation cohérente et les commentaires explicatifs rendent les scripts maintenables sur le long terme.

  • La sécurité implique d’éviter eval, de ne jamais mettre de secrets dans les scripts, de désactiver set -x autour des sections sensibles, et d’utiliser umask pour les fichiers de données confidentielles.

Ce livre vous a accompagné depuis les fondements de Linux et du terminal jusqu’aux techniques avancées d’automatisation, en passant par la programmation shell, les outils système, les expressions régulières et l’interopérabilité avec d’autres langages. Le shell est un outil vivant : sa maîtrise progresse par la pratique quotidienne, la lecture de scripts bien écrits, et l’instauration d’une discipline de vérification systématique avec ShellCheck. Chaque script que vous écrivez est une opportunité d’appliquer ces principes et de les faire progresser.