Redirections et pipes#

Hide code cell source

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

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

La puissance du shell Unix repose sur un concept remarquablement simple : les programmes ne connaissent pas la source de leurs données ni la destination de leurs résultats. Ils lisent sur un canal appelé entrée standard et écrivent sur deux canaux appelés sortie standard et sortie d’erreur. Le shell peut rediriger ces canaux vers des fichiers ou les connecter entre programmes à l’aide de pipes. Ce mécanisme, conçu dans les années 1970 par Douglas McIlroy aux Bell Labs, reste aujourd’hui l’un des fondements les plus élégants et les plus puissants de l’informatique en ligne de commande.

Les trois flux standard#

Chaque processus Unix hérite à sa création de trois descripteurs de fichiers ouverts, numérotés de 0 à 2 :

Définition 17 (Les trois flux standard)

Les trois flux standard sont des canaux de communication prédéfinis associés à tout processus :

  • stdin (descripteur 0) — Standard Input : le canal par lequel le processus lit ses données. Par défaut, il est connecté au clavier. Toute commande qui « attend » que vous tapiez quelque chose lit sur stdin.

  • stdout (descripteur 1) — Standard Output : le canal sur lequel le processus écrit ses résultats. Par défaut, il est connecté au terminal. C’est ce que l’on voit normalement s’afficher.

  • stderr (descripteur 2) — Standard Error : le canal réservé aux messages d’erreur et de diagnostic. Par défaut, lui aussi connecté au terminal, mais indépendant de stdout. Cette séparation est fondamentale : elle permet de rediriger les erreurs séparément des résultats.

Cette séparation entre stdout et stderr est un choix de conception délibéré. Elle permet par exemple d’écrire les résultats dans un fichier tout en voyant les erreurs s’afficher dans le terminal, ou à l’inverse de rediriger les erreurs dans un fichier de log sans polluer la sortie principale.

Remarque 18

Le système de descripteurs de fichiers est bien plus général : chaque processus peut ouvrir jusqu’à des milliers de descripteurs supplémentaires (3, 4, 5…) pour lire ou écrire des fichiers, des sockets réseau, des pipes, etc. Les redirections du shell manipulent ces descripteurs : elles les ferment, les rouvrent sur d’autres fichiers ou les font pointer vers d’autres descripteurs. La commande ls -la /proc/$$/fd permet de lister les descripteurs ouverts du shell courant.

Les redirections de base#

Redirection de stdout (> et >>)#

L’opérateur > redirige la sortie standard vers un fichier. Si le fichier existe, il est écrasé ; s’il n’existe pas, il est créé :

# Écrire la liste des fichiers dans un fichier
ls -la > liste_fichiers.txt

# Créer un fichier vide (ou vider un fichier existant)
> fichier_vide.txt

# Écrire un message dans un fichier
echo "Bonjour le monde" > message.txt

L’opérateur >> ajoute (append) à la fin du fichier sans l’écraser :

# Ajouter une entrée à un fichier de log
echo "$(date): Démarrage du service" >> /var/log/mon_service.log

# Accumuler des résultats
echo "Résultats du test 1 :" >> rapport.txt
./test1.sh >> rapport.txt
echo "Résultats du test 2 :" >> rapport.txt
./test2.sh >> rapport.txt

Redirection de stdin (<)#

L’opérateur < connecte le fichier spécifié à l’entrée standard de la commande :

# Trier le contenu d'un fichier
sort < données.txt

# Équivalent (les deux formes sont équivalentes)
sort données.txt
cat données.txt | sort

# Envoyer un fichier comme corps d'un email
mail -s "Rapport quotidien" destinataire@example.com < rapport.txt

# Alimenter une commande interactive avec des données préparées
mysql -u root -p ma_base < script.sql

Redirection de stderr (2>, 2>>)#

# Rediriger les erreurs vers un fichier
ls /repertoire_inexistant 2> erreurs.txt

# Ajouter les erreurs à un fichier de log existant
find / -name "*.conf" 2>> erreurs_find.log

# Supprimer les messages d'erreur (les envoyer dans le néant)
find / -name "*.conf" 2> /dev/null

# Afficher les erreurs mais pas les résultats normaux
find / -name "*.conf" 1>/dev/null

Rediriger stderr vers stdout (2>&1)#

La syntaxe 2>&1 signifie : « connecte le descripteur 2 (stderr) au même endroit que le descripteur 1 (stdout) ». L’ordre des redirections est crucial :

# Capturer stdout et stderr dans le même fichier
commande > sortie_totale.txt 2>&1

# ORDRE IMPORTANT : cette commande est différente
# Ici, stderr va vers le terminal (l'ancien stdout), stdout va dans le fichier
commande 2>&1 > fichier.txt   # INCORRECT pour capturer les deux

# La forme abrégée &> (bash uniquement) redirige les deux simultanément
commande &> sortie_totale.txt

# Ajouter stdout et stderr à un fichier
commande >> journal.txt 2>&1

Remarque 19

La syntaxe 2>&1 se lit de droite à gauche : « le descripteur 2 prend comme cible ce que pointe actuellement le descripteur 1 ». C’est pourquoi l’ordre importe : dans > fichier 2>&1, au moment où 2>&1 est évalué, le descripteur 1 pointe déjà vers fichier, donc stderr ira aussi dans fichier. Dans 2>&1 > fichier, au moment où 2>&1 est évalué, le descripteur 1 pointe encore vers le terminal, donc stderr ira vers le terminal.

Le périphérique /dev/null#

/dev/null est un fichier spécial qui se comporte comme un puits sans fond : tout ce qui y est écrit est silencieusement discardé, et toute lecture sur /dev/null retourne immédiatement EOF.

# Supprimer complètement la sortie d'une commande
commande > /dev/null 2>&1

# Lancer une commande en silence total
./script_bruyant.sh &>/dev/null

# Vérifier si une commande réussit sans afficher de sortie
if grep -q "motif" fichier.txt 2>/dev/null; then
    echo "Motif trouvé"
fi

Les pipes : connecter les commandes#

Le pipe | est l’opérateur qui connecte la sortie standard d’une commande à l’entrée standard de la suivante. C’est le mécanisme fondamental de la composition de commandes Unix.

Définition 18 (Pipe (tube))

Un pipe est un canal de communication unidirectionnel entre deux processus. Dans le shell, l’opérateur | crée un pipe et :

  1. Lance les deux commandes simultanément (pas l’une après l’autre).

  2. Connecte le stdout de la commande gauche au stdin de la commande droite.

  3. Chaque commande s’exécute dans un sous-shell séparé.

  4. Le pipe se ferme automatiquement quand la commande productrice termine (envoi de SIGPIPE à la commande consommatrice si elle tente d’écrire après la fermeture du pipe).

Construction de pipelines#

# Pipeline simple : lister et trier
ls -la | sort -k5,5rn

# Pipeline en trois étapes
cat /etc/passwd | cut -d':' -f1 | sort

# Les 10 fichiers les plus récemment modifiés
find . -type f -printf "%T@ %p\n" | sort -rn | head -10 | cut -d' ' -f2-

# Compter le nombre de connexions par état TCP
ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn

# Analyser l'utilisation des commandes dans l'historique
history | awk '{print $2}' | sort | uniq -c | sort -rn | head -20

Sous-shells et variables#

Un aspect important des pipes est que chaque commande d’un pipeline s’exécute dans un sous-shell. Cela a des implications sur les variables :

# Ce code ne fonctionne PAS comme attendu
total=0
cat nombres.txt | while read n; do
    total=$((total + n))
done
echo "Total : $total"   # Affiche 0 ! La variable a été modifiée dans un sous-shell.

# Solution avec redirection (pas de sous-shell pour while)
total=0
while read n; do
    total=$((total + n))
done < nombres.txt
echo "Total : $total"   # Fonctionne correctement

# Solution avec lastpipe (bash 4.2+)
set +m  # Désactiver le job control
shopt -s lastpipe
total=0
cat nombres.txt | while read n; do
    total=$((total + n))
done
echo "Total : $total"   # Fonctionne avec lastpipe

SIGPIPE et gestion des erreurs#

Quand la commande de droite d’un pipe arrête de lire (par exemple head -5 après avoir lu 5 lignes), le noyau envoie le signal SIGPIPE à la commande de gauche pour lui signaler qu’elle peut s’arrêter. Ce comportement est généralement transparent, mais il peut générer des messages d’erreur avec certaines commandes :

# yes génère "y" en boucle infinie, head l'interrompt proprement
yes | head -5

# Certaines commandes affichent un avertissement à la réception de SIGPIPE
# On peut ignorer le code de retour avec || true
quelque_chose | head -1 || true

Substitution de processus#

La substitution de processus est une fonctionnalité avancée de Bash qui permet d’utiliser la sortie d’une commande comme si c’était un fichier.

Définition 19 (Substitution de processus)

La substitution de processus prend deux formes :

  • <(commande) : exécute la commande et rend sa sortie disponible comme un fichier en lecture. Bash crée un fichier spécial dans /dev/fd/ (ou un tube nommé selon le système) que la commande cible peut ouvrir comme un fichier ordinaire.

  • >(commande) : de manière symétrique, crée un fichier en écriture dont le contenu est envoyé vers stdin de la commande spécifiée.

Cette fonctionnalité est particulièrement utile quand une commande attend des arguments de type fichier et ne peut pas lire sur stdin.

Comparer des sorties de commandes#

# Comparer le contenu de deux répertoires
diff <(ls répertoire1/) <(ls répertoire2/)

# Comparer deux fichiers triés sans créer de fichiers temporaires
diff <(sort fichier1.txt) <(sort fichier2.txt)

# Vérifier que deux commandes produisent le même résultat
diff <(commande1) <(commande2) && echo "Identiques" || echo "Différents"

Alimenter des commandes nécessitant des fichiers#

# wc -l peut prendre plusieurs fichiers et afficher les totaux par fichier
wc -l <(grep "ERREUR" app.log) <(grep "ERREUR" access.log)

# join nécessite des fichiers (et non stdin) pour les deux entrées
join <(sort fichier1.csv) <(sort fichier2.csv)

# Comparer la sortie de deux branches Git
diff <(git show branche1:fichier.py) <(git show branche2:fichier.py)

Substitution de processus en écriture#

# Écrire simultanément dans deux commandes
commande | tee >(gzip > sortie.gz) >(wc -l > compte.txt) > /dev/null

# Journaliser et afficher simultanément
./script.sh > >(tee -a journal.log) 2> >(tee -a erreurs.log >&2)

La commande xargs — construire des arguments#

La commande xargs lit des données sur stdin et les transforme en arguments de la commande spécifiée. C’est essentiel car beaucoup de commandes ne lisent pas sur stdin mais attendent des arguments.

```{prf:definition} Fonctionnement de xargs :label: definition-07-04 xargs lit les éléments de stdin (séparés par défaut par des espaces et des retours à la ligne) et les passe en argument à la commande cible. Sans options, xargs passe le maximum d’arguments possible en une seule invocation de la commande.

Options importantes :

  • -I {} : substitution. Chaque occurrence de {} dans la commande est remplacée par l’élément lu sur stdin. La commande est invoquée une fois par élément.

  • -n N : passe au maximum N arguments par invocation de la commande.

  • -P N (parallel) : exécute jusqu’à N processus en parallèle.

  • -0 (null) : utilise le caractère nul (\0) comme séparateur. À utiliser avec find -print0 pour gérer les noms de fichiers contenant des espaces.

  • -t (trace) : affiche chaque commande avant de l’exécuter.

  • -r (no-run-if-empty) : ne rien faire si stdin est vide.


### Usages courants de `xargs`

```bash
# Supprimer tous les fichiers .tmp trouvés
find . -name "*.tmp" | xargs rm -f

# Version sûre pour les noms de fichiers avec espaces
find . -name "*.tmp" -print0 | xargs -0 rm -f

# Compter les lignes de tous les fichiers Python du projet
find . -name "*.py" | xargs wc -l

# Convertir des images en parallèle (4 processus simultanés)
find . -name "*.png" | xargs -P4 -I{} convert {} -quality 85 {}.jpg

# Télécharger une liste d'URLs
cat urls.txt | xargs -n1 -P4 wget -q

# Passer des arguments à une commande complexe
echo "alice bob carol" | xargs -n1 | xargs -I{} bash -c 'echo "Bonjour, {}!"'

xargs avec des commandes complexes#

# Rechercher un motif dans tous les fichiers d'une liste
cat fichiers_a_analyser.txt | xargs grep -l "motif_important"

# Créer des répertoires à partir d'une liste
cat liste_projets.txt | xargs -I{} mkdir -p projets/{}

# Archiver des fichiers modifiés récemment
find . -newer référence.txt -type f | xargs tar czf archive.tar.gz

# Tester des URLs en parallèle et afficher le code HTTP
cat urls.txt | xargs -P10 -I{} curl -s -o /dev/null -w "{}: %{http_code}\n" {}

La commande tee — dupliquer le flux#

La commande tee lit sur stdin et écrit simultanément sur stdout et dans un ou plusieurs fichiers. Son nom fait référence au raccord en T de la plomberie.

# Afficher la sortie ET l'enregistrer dans un fichier
commande | tee journal.txt

# Ajouter à un fichier existant (-a pour append)
commande | tee -a journal.txt

# Écrire dans plusieurs fichiers simultanément
commande | tee fichier1.txt fichier2.txt

# Utilisation classique : voir la sortie ET continuer le pipeline
find . -name "*.py" | tee liste_py.txt | wc -l
# Affiche le nombre de fichiers ET enregistre la liste dans liste_py.txt

# Journaliser stdout et stderr séparément tout en continuant le pipeline
./script.sh 2> >(tee erreurs.log >&2) | tee sortie.log | analyse_résultats

Here-documents et here-strings#

Les here-documents et here-strings sont des mécanismes permettant de fournir des données directement dans le code shell, sans fichier externe.

```{prf:definition} Here-document (<<EOF) :label: definition-07-05 Un here-document est une forme de redirection stdin qui permet d’écrire du texte multi-lignes directement dans le script. La syntaxe est :

commande <<MARQUEUR
ligne 1
ligne 2
...
MARQUEUR

Le texte entre les deux occurrences du marqueur est envoyé sur stdin de la commande. L’interpolation des variables et des substitutions de commandes est active par défaut. Pour la désactiver et traiter le texte littéralement, on place le marqueur entre guillemets simples ou on l’échappe : <<'EOF' ou <<\EOF.


### Here-documents en pratique

```bash
# Écrire un fichier de configuration multi-lignes
cat > /etc/mon_service.conf <<EOF
# Configuration générée le $(date)
serveur=localhost
port=8080
utilisateur=$USER
mode=production
EOF

# Envoyer un email avec contenu HTML
mail -s "Rapport" -a "Content-Type: text/html" destinataire@example.com <<EOF
<h1>Rapport du $(date +%Y-%m-%d)</h1>
<p>Bonjour,</p>
<p>Veuillez trouver ci-joint le rapport quotidien.</p>
EOF

# Exécuter plusieurs commandes MySQL
mysql -u root -p ma_base <<'EOF'
SELECT COUNT(*) FROM utilisateurs;
SELECT nom, email FROM utilisateurs WHERE actif = 1;
UPDATE logs SET traité = TRUE WHERE date < NOW() - INTERVAL 30 DAY;
EOF

# Here-document avec indentation (<<- supprime les tabulations initiales)
if condition; then
    cat <<-EOF
        Ce texte est indenté dans le script
        mais s'affiche sans les tabulations initiales
        (seules les tabulations, pas les espaces, sont supprimées)
    EOF
fi

Here-strings (<<<)#

# Envoyer une chaîne sur stdin d'une commande
grep "motif" <<< "texte à analyser contenant le motif"

# Équivalent de echo "texte" | commande, mais sans sous-shell
read prénom nom <<< "Alice Dupont"
echo "$prénom"  # Alice
echo "$nom"     # Dupont

# Tester une expression régulière
if grep -qE "^[0-9]+$" <<< "$variable"; then
    echo "$variable est un entier"
fi

# Passer une chaîne à bc pour calcul
résultat=$(bc <<< "scale=4; 355/113")
echo "π ≈ $résultat"

Remarque 20

La here-string <<< a un avantage sur echo "..." | commande : elle ne crée pas de sous-shell. Cela signifie que les variables modifiées à l’intérieur d’une structure lisant depuis une here-string sont visibles dans le shell parent. De plus, avec la here-string, la commande cible peut utiliser un pseudo-fichier au lieu d’un vrai pipe, ce qui peut être plus efficace pour les petites données.

Redirections avancées#

Ouvrir et fermer des descripteurs de fichiers#

Le shell permet de manipuler directement les descripteurs de fichiers, ce qui est utile dans les scripts avancés :

# Ouvrir le descripteur 3 en lecture sur un fichier
exec 3< données.txt
# Lire une ligne depuis le descripteur 3
read -u 3 ligne
# Fermer le descripteur 3
exec 3<&-

# Ouvrir le descripteur 4 en écriture
exec 4> journal.txt
# Écrire sur le descripteur 4
echo "Entrée de journal" >&4
# Fermer le descripteur 4
exec 4>&-

# Sauvegarder et restaurer stdout
exec 5>&1          # Sauvegarder stdout dans le descripteur 5
exec > sortie.txt  # Rediriger stdout vers un fichier
echo "Ceci va dans le fichier"
exec 1>&5          # Restaurer stdout depuis le descripteur 5
exec 5>&-          # Fermer le descripteur 5
echo "Ceci s'affiche à nouveau dans le terminal"

Redirections dans les blocs#

# Rediriger la sortie de tout un bloc
{
    echo "En-tête du rapport"
    date
    df -h
    free -h
} > rapport_système.txt

# Rediriger l'entrée d'un bloc
while read ligne; do
    echo "Lu : $ligne"
done < données.txt

# Redirections dans les fonctions
générer_rapport() {
    echo "=== Rapport ==="
    echo "Date : $(date)"
    echo "Utilisateur : $USER"
} 2>&1 | tee rapport.txt

Visualisation : les flux stdin/stdout/stderr#

Hide code cell source

fig, axes = plt.subplots(1, 2, figsize=(16, 8))

palette = sns.color_palette("muted", 8)
couleur_stdin  = palette[0]   # bleu
couleur_stdout = palette[2]   # vert
couleur_stderr = palette[3]   # orange/rouge
couleur_prog   = palette[1]   # indigo
couleur_fichier = palette[5]  # violet
couleur_null   = '#aaaaaa'

# ============================================================
# Schéma 1 : Les trois flux sans redirection
# ============================================================
ax = axes[0]
ax.set_xlim(-1, 11)
ax.set_ylim(-1, 9)
ax.axis('off')
ax.set_title('Les trois flux standard (sans redirection)',
             fontsize=13, fontweight='bold')

def boite(ax, x, y, w, h, couleur, texte, sous_texte=None, alpha=0.25):
    rect = patches.FancyBboxPatch(
        (x - w/2, y - h/2), w, h,
        boxstyle="round,pad=0.15", linewidth=2,
        edgecolor=couleur, facecolor=(*couleur[:3], alpha)
    )
    ax.add_patch(rect)
    ax.text(x, y + (0.3 if sous_texte else 0), texte,
            ha='center', va='center', fontsize=11, fontweight='bold',
            color=couleur)
    if sous_texte:
        ax.text(x, y - 0.45, sous_texte,
                ha='center', va='center', fontsize=8.5, color='#555555',
                style='italic')

def fleche(ax, x1, y1, x2, y2, couleur, label=None, style='->', lw=2.5):
    ax.annotate('', xy=(x2, y2), xytext=(x1, y1),
                arrowprops=dict(arrowstyle=style, color=couleur, lw=lw))
    if label:
        mx, my = (x1+x2)/2, (y1+y2)/2
        ax.text(mx + 0.1, my + 0.25, label, ha='left', va='center',
                fontsize=9, fontweight='bold', color=couleur,
                fontfamily='monospace')

# Clavier
boite(ax, 2, 7, 2.5, 1.2, couleur_stdin, 'Clavier', 'source stdin')
# Terminal (stdout)
boite(ax, 8, 7, 2.5, 1.2, couleur_stdout, 'Terminal', 'sortie stdout')
# Terminal (stderr)
boite(ax, 8, 2, 2.5, 1.2, couleur_stderr, 'Terminal', 'sortie stderr')
# Programme
boite(ax, 5, 4.5, 2.8, 1.6, couleur_prog, 'Programme', 'processus', alpha=0.35)
ax.text(5, 4.1, 'pid = $$', ha='center', va='center', fontsize=8,
        fontfamily='monospace', color=palette[1], style='italic')

# Descripteurs
for desc, x_desc, y_desc, couleur_d, nom in [
    ('fd 0', 3.8, 6.0, couleur_stdin, 'stdin'),
    ('fd 1', 6.5, 6.0, couleur_stdout, 'stdout'),
    ('fd 2', 6.5, 3.3, couleur_stderr, 'stderr'),
]:
    ax.text(x_desc, y_desc, f'{desc}\n({nom})', ha='center', va='center',
            fontsize=8.5, fontweight='bold', color=couleur_d,
            fontfamily='monospace',
            bbox=dict(boxstyle='round,pad=0.2', facecolor='white',
                      edgecolor=couleur_d, alpha=0.8))

# Flèches
fleche(ax, 2, 6.38, 3.5, 5.3, couleur_stdin, '0')
fleche(ax, 6.5, 5.3, 7.5, 6.38, couleur_stdout, '1')
fleche(ax, 6.5, 3.8, 7.5, 2.62, couleur_stderr, '2')

# Légende des numéros
for num, y_l, couleur_l, nom_l in [(0, 1.2, couleur_stdin, 'stdin'),
                                     (1, 0.7, couleur_stdout, 'stdout'),
                                     (2, 0.2, couleur_stderr, 'stderr')]:
    ax.add_patch(patches.Circle((1.0, y_l), 0.2, color=couleur_l, zorder=5))
    ax.text(1.0, y_l, str(num), ha='center', va='center', fontsize=9,
            fontweight='bold', color='white', zorder=6)
    ax.text(1.4, y_l, nom_l, ha='left', va='center', fontsize=9,
            color=couleur_l, fontweight='bold')

# ============================================================
# Schéma 2 : Les redirections courantes
# ============================================================
ax2 = axes[1]
ax2.set_xlim(-0.5, 11)
ax2.set_ylim(-0.5, 9)
ax2.axis('off')
ax2.set_title('Redirections courantes', fontsize=13, fontweight='bold')

redirections = [
    ('cmd > fichier',   '>',    'stdout → fichier (écrase)',    '#27ae60'),
    ('cmd >> fichier',  '>>',   'stdout → fichier (ajoute)',    '#2ecc71'),
    ('cmd < fichier',   '<',    'fichier → stdin',              '#2980b9'),
    ('cmd 2> fichier',  '2>',   'stderr → fichier',             '#e67e22'),
    ('cmd 2>&1',        '2>&1', 'stderr → même cible que stdout','#e74c3c'),
    ('cmd &> fichier',  '&>',   'stdout+stderr → fichier',      '#8e44ad'),
    ('cmd 2>/dev/null', '2>…',  'stderr → /dev/null (silence)', '#95a5a6'),
    ('cmd | cmd2',      '|',    'stdout → stdin de cmd2',       '#16a085'),
]

for i, (syntaxe, opérateur, description, couleur) in enumerate(redirections):
    y = 8.2 - i * 1.0
    # Fond de la ligne
    fond = patches.FancyBboxPatch(
        (0.1, y - 0.35), 10.7, 0.72,
        boxstyle="round,pad=0.05", linewidth=1.5,
        edgecolor=couleur, facecolor=couleur, alpha=0.10
    )
    ax2.add_patch(fond)
    # Opérateur en monospace
    ax2.text(1.0, y, opérateur, ha='center', va='center',
             fontsize=12, fontweight='bold', color=couleur,
             fontfamily='monospace',
             bbox=dict(boxstyle='round,pad=0.2', facecolor='white',
                       edgecolor=couleur, alpha=0.9))
    # Syntaxe complète
    ax2.text(3.2, y + 0.08, syntaxe, ha='left', va='center',
             fontsize=10, fontweight='bold', color='#2c3e50',
             fontfamily='monospace')
    # Description
    ax2.text(3.2, y - 0.22, description, ha='left', va='center',
             fontsize=8.5, color='#555555', style='italic')

plt.tight_layout()
plt.show()
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[2], line 126
    122     # Description
    123     ax2.text(3.2, y - 0.22, description, ha='left', va='center',
    124              fontsize=8.5, color='#555555', style='italic')
--> 126 plt.tight_layout()
    127 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: 
pid = $$
      ^
ParseException: Expected end of text, found '$'  (at char 6), (line:1, col:7)
Error in callback <function _draw_all_if_interactive at 0x7f45cb08f4c0> (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: 
pid = $$
      ^
ParseException: Expected end of text, found '$'  (at char 6), (line:1, col:7)
---------------------------------------------------------------------------
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: 
pid = $$
      ^
ParseException: Expected end of text, found '$'  (at char 6), (line:1, col:7)
<Figure size 1600x800 with 2 Axes>

Hide code cell source

# Visualisation : pipeline multi-étapes avec flux
fig, ax = plt.subplots(figsize=(16, 5))
ax.set_xlim(-0.5, 16)
ax.set_ylim(-1.5, 5.5)
ax.axis('off')
ax.set_title('Pipeline : connexion des commandes via les pipes',
             fontsize=14, fontweight='bold', pad=15)

palette2 = sns.color_palette("muted", 5)

commandes = [
    ('find .\n-name "*.log"', 2.0),
    ('grep\n"ERROR"', 5.5),
    ('awk\n\'{print $5}\'', 9.0),
    ('sort |\nuniq -c', 12.5),
    ('head -10', 15.0),
]

boite_w, boite_h = 2.6, 2.0
boite_y = 1.5

for i, (cmd, x) in enumerate(commandes):
    c = palette2[i % len(palette2)]
    rect = patches.FancyBboxPatch(
        (x - boite_w/2, boite_y), boite_w, boite_h,
        boxstyle="round,pad=0.12", linewidth=2.2,
        edgecolor=c, facecolor=(*c[:3], 0.20)
    )
    ax.add_patch(rect)
    ax.text(x, boite_y + boite_h/2, cmd,
            ha='center', va='center', fontsize=9.5, fontweight='bold',
            color=c, fontfamily='monospace')

    if i < len(commandes) - 1:
        next_x = commandes[i+1][1]
        # Ligne de pipe
        x_start = x + boite_w/2 + 0.05
        x_end   = next_x - boite_w/2 - 0.05
        mid_x   = (x_start + x_end) / 2
        mid_y   = boite_y + boite_h/2

        ax.annotate('',
                    xy=(x_end, mid_y),
                    xytext=(x_start, mid_y),
                    arrowprops=dict(arrowstyle='->', color='#555555', lw=2.2))
        # Symbole pipe sur la flèche
        ax.text(mid_x, mid_y + 0.4, '|', ha='center', va='center',
                fontsize=16, color='#888888', fontweight='bold')
        # Label stdin/stdout
        ax.text(mid_x, mid_y - 0.5, 'stdout → stdin',
                ha='center', va='center', fontsize=7.5, color='#888888',
                style='italic')

# Entrée et sortie finales
ax.text(0.2, boite_y + boite_h/2, 'Système\nde fichiers',
        ha='center', va='center', fontsize=8, color='#666666',
        style='italic')
ax.annotate('', xy=(commandes[0][1] - boite_w/2 - 0.1, boite_y + boite_h/2),
            xytext=(0.7, boite_y + boite_h/2),
            arrowprops=dict(arrowstyle='->', color='#666666', lw=1.5, linestyle='dashed'))

ax.text(15.9, boite_y + boite_h/2 + 0.3, 'Terminal\n(stdout)',
        ha='left', va='center', fontsize=8, color='#27ae60', style='italic')

# Note en bas
ax.text(8, -0.8,
        'Chaque commande s\'exécute dans un sous-shell — '
        'les pipes sont créés avant le lancement des processus',
        ha='center', va='center', fontsize=9.5, color='#555555',
        style='italic',
        bbox=dict(boxstyle='round,pad=0.3', facecolor='#f8f9fa',
                  edgecolor='#dee2e6', alpha=0.9))

plt.tight_layout()
plt.show()

Exemples avancés de pipelines#

Monitoring et analyse en temps réel#

# Surveiller les nouvelles lignes d'un log en les filtrant
tail -f /var/log/syslog | grep --line-buffered "ERREUR\|WARN" | \
    while read ligne; do
        echo "[$(date '+%H:%M:%S')] $ligne"
    done

# Calculer la taille cumulée des fichiers de log
find /var/log -name "*.log" -type f | \
    xargs du -b 2>/dev/null | \
    awk '{total += $1} END {printf "Total : %.2f Mo\n", total/1048576}'

# Identifier les fichiers en double (même contenu, noms différents)
find . -type f -exec md5sum {} \; | \
    sort | \
    uniq -d -w32 | \
    awk '{print $2}'

Traitement de données structurées#

# Extraire et reformater des données JSON avec jq et awk
curl -s "https://api.example.com/users" | \
    jq -r '.[] | "\(.nom),\(.email),\(.age)"' | \
    sort -t',' -k3,3n | \
    awk -F',' 'BEGIN {print "Nom;Email;Âge"} {print $1";"$2";"$3}' | \
    tee rapport_utilisateurs.csv | \
    wc -l

# Convertir un fichier de log en CSV
awk '{
    match($0, /\[([^\]]+)\]/, date_arr)
    match($0, /"([A-Z]+) ([^ ]+)/, méthode_arr)
    print date_arr[1] "," méthode_arr[1] "," $9 "," $10
}' /var/log/apache2/access.log | \
    sort -t',' -k3,3n | \
    tee accès.csv | head -5

Exemple 18 (Pipeline de traitement de données de capteurs)

Supposons un fichier capteurs.txt avec des relevés de température toutes les minutes, au format timestamp,capteur_id,température :

# Calculer les statistiques par capteur
sort -t',' -k2,2 capteurs.txt | \
    awk -F',' '
    {
        somme[$2] += $3
        n[$2]++
        if (!min[$2] || $3 < min[$2]) min[$2] = $3
        if ($3 > max[$2]) max[$2] = $3
    }
    END {
        printf "%-15s %8s %8s %8s %8s\n", "Capteur", "Moyenne", "Min", "Max", "N"
        printf "%-15s %8s %8s %8s %8s\n", "-------", "-------", "---", "---", "-"
        for (c in somme)
            printf "%-15s %8.2f %8.2f %8.2f %8d\n",
                   c, somme[c]/n[c], min[c], max[c], n[c]
    }' | sort -k2,2rn

# Détecter les anomalies (température > 80°C)
awk -F',' '$3 > 80 {
    print "ALERTE:", $1, "- Capteur", $2, "- Température:", $3 "°C"
}' capteurs.txt | tee alertes.log | wc -l

## Résumé

Dans ce chapitre, nous avons maîtrisé les mécanismes de communication entre processus et le shell :

- Les **trois flux standard** — stdin (0), stdout (1) et stderr (2) — sont des descripteurs de fichiers hérités par chaque processus. Leur séparation permet un contrôle fin de la communication entre programmes.
- Les **redirections** permettent de réorienter ces flux : `>` écrase un fichier, `>>` ajoute, `<` connecte un fichier à stdin. La redirection `2>` cible stderr, `2>&1` fusionne stderr dans stdout, et `&>` redirige les deux simultanément. `/dev/null` absorbe silencieusement tout ce qui lui est envoyé.
- Les **pipes** `|` connectent le stdout d'une commande au stdin de la suivante. Les commandes s'exécutent en parallèle dans des sous-shells ; SIGPIPE gère proprement l'arrêt de la chaîne.
- La **substitution de processus** `<(cmd)` et `>(cmd)` permet d'utiliser la sortie ou l'entrée d'une commande comme un fichier, contournant la limitation des programmes qui n'acceptent que des arguments de type fichier.
- **`xargs`** transforme stdin en arguments de commande, avec `-I{}` pour la substitution, `-n` pour limiter le nombre d'arguments par appel et `-P` pour la parallélisation.
- **`tee`** duplique un flux : il écrit simultanément sur stdout et dans un fichier, permettant d'observer et d'enregistrer sans interrompre le pipeline.
- Les **here-documents** (`<<EOF`) et **here-strings** (`<<<`) permettent d'inclure des données multi-lignes directement dans les scripts sans fichiers temporaires.

Dans le chapitre suivant, nous aborderons le cœur du scripting Bash : les **variables et les types** — comment déclarer des variables, maîtriser les différents types de guillemets, gérer l'environnement et effectuer des calculs arithmétiques.