Tom Daluzeau
← Parcours Python · système
Python · système · leçon 3 / 8Ch. 4 · Python et le système13 minTerminée

subprocess : lancer une commande depuis Python

Lancer une commande avec subprocess.run et une liste d'arguments, lire son code de retour et sa sortie, transformer un échec en exception avec check=True, borner l'attente avec timeout, et adapter la commande au système avec platform.system et shutil.which.

Mis à jour le

À recouper avec le cours. Cette leçon a été écrite à partir du plan du chapitre, avant relecture avec les supports. En cas de doute, le cours fait foi.

Pourquoi on voit ça

Python ne sait pas tout faire seul. Pour savoir si un serveur répond, le plus simple est de lancer ping. Pour lire l’état d’un dépôt, c’est git. Pour l’espace disque, df. Ces commandes existent déjà : ton outil doit les lancer, puis lire ce qu’elles répondent.

C’est le rôle de subprocess.run. La fonction est simple, mais chaque argument change ce que tu récupères, et une mauvaise habitude (shell=True) ouvre une vraie faille de sécurité. Attends-toi à plusieurs extraits de ce genre au contrôle.

subprocess.run et la liste d’arguments

La commande se donne sous forme de liste : le programme d’abord, puis un élément par argument.

import subprocess

subprocess.run(["ping", "-c", "1", "web-1"])

Pas de chaîne unique, pas d’espaces à gérer : ["ping", "-c", "1", "web-1"], et non "ping -c 1 web-1".

ton programme subprocess.run(…) "ping" "-c" "1" "web-1" une case = un argument, aucun shell processus fils ping r : le résultat, quand la commande a fini r.returncode 0 = succès r.stdout le résultat normal r.stderr les erreurs fin de ping
La liste devient les arguments du processus fils. À la fin, run rend un résultat avec trois champs.

Pourquoi pas shell=True

Avec shell=True, tu donnes une seule chaîne, et c’est un shell qui la lit. Or un shell interprète des caractères spéciaux : ;, |, &&, $, *. Si un morceau de la chaîne vient de l’extérieur (un inventaire, un argument, une API), celui qui l’écrit peut y glisser une commande.

host = "web-1; echo PIRATE"

subprocess.run(["echo", "ping", host])
subprocess.run(f"echo ping {host}", shell=True)
ping web-1; echo PIRATE
ping web-1
PIRATE

Première ligne : la liste transmet host comme un seul argument, tel quel. Le point-virgule n’est qu’un caractère. Lignes suivantes : le shell a coupé au point-virgule et exécuté une deuxième commande. Remplace echo PIRATE par rm -rf / et tu vois le problème. On appelle ça une injection de commande.

liste : le ; reste dans la case echo ping web-1; echo PIRATE un seul processus : echo ping web-1; echo PIRATE le point-virgule n'est qu'un caractère shell=True : le shell coupe au ; echo ping web-1; echo PIRATE echo ping web-1 echo PIRATE ping web-1 PIRATE une deuxième commande s'est glissée
Avec une liste, rien n'est interprété. Avec shell=True, le texte venu de l'extérieur peut ajouter une commande.

Règle : toujours une liste, jamais shell=True.

Le code de retour : returncode

run attend la fin de la commande et renvoie un objet résultat. Son champ returncode est le code de sortie de la commande : 0 = tout s’est bien passé, autre chose = échec. C’est le $? du shell.

Pour s’entraîner, deux commandes minuscules : true réussit toujours (code 0), false échoue toujours (code 1).

r = subprocess.run(["true"])
print(r.returncode)
r = subprocess.run(["false"])
print(r.returncode)
0
1

Le point à retenir : un échec de la commande ne lève pas d’exception. run te donne le code, et c’est à toi de le tester.

r = subprocess.run(["ping", "-c", "1", "web-1"])
if r.returncode == 0:
    print("web-1 répond")
else:
    print("web-1 ne répond pas")

Récupérer la sortie : capture_output et text

Par défaut, ce que la commande écrit part directement dans le terminal. Python ne le voit pas : r.stdout vaut None.

Pour le récupérer, il faut deux arguments :

Arguments r.stdout vaut
aucun None (la sortie s’affiche dans le terminal)
capture_output=True des octets : b'ok\n'
capture_output=True, text=True du texte : 'ok\n'
r = subprocess.run(["echo", "web-1"], capture_output=True, text=True)
print(repr(r.stdout))
print(r.stdout.strip())
'web-1\n'
web-1

La sortie se termine par un saut de ligne : pense à strip() avant de comparer ou d’afficher.

Une commande a deux canaux de sortie, et tu récupères les deux :

  • r.stdout : le résultat normal ;
  • r.stderr : les messages d’erreur.

Quand ls vise un dossier qui n’existe pas, r.stdout est une chaîne vide, et le message « No such file or directory » est dans r.stderr. Le returncode est alors différent de 0.

check=True : l’échec devient une exception

Tester returncode après chaque commande est vite lourd. Avec check=True, run lève CalledProcessError dès que le code de retour n’est pas 0.

try:
    subprocess.run(["false"], check=True)
    print("ok")
except subprocess.CalledProcessError as e:
    print("échec", e.returncode)
échec 1

L’exception porte le code dans e.returncode. Si la commande réussit, rien n’est levé et le code continue normalement.

Sans check Avec check=True
Commande réussie (code 0) returncode vaut 0 rien de spécial
Commande en échec returncode vaut le code, pas d’exception CalledProcessError

timeout : ne pas attendre pour toujours

Un ping vers une machine éteinte, une commande qui attend une saisie : sans limite, ton outil reste bloqué. timeout= donne un délai maximal en secondes. Passé ce délai, run arrête la commande et lève TimeoutExpired.

try:
    subprocess.run(["sleep", "5"], timeout=1)
    print("terminé")
except subprocess.TimeoutExpired:
    print("trop long")
trop long

Le message arrive au bout d’une seconde, pas de cinq. Ne confonds pas les deux exceptions : CalledProcessError = la commande a fini, mais mal ; TimeoutExpired = la commande n’a pas fini à temps.

run(cmd, check=True, timeout=5) finie avant 5 secondes ? non TimeoutExpired la commande est arrêtée sans timeout : attente sans fin oui returncode == 0 ? oui le code continue non CalledProcessError e.returncode porte le code
TimeoutExpired : la commande n'a pas fini à temps. CalledProcessError : elle a fini, mais mal. Sans check, ce second cas ne lève rien.

S’adapter à la machine : platform.system et shutil.which

Ton outil tourne sur ton poste et sur des serveurs. Les commandes ne sont pas les mêmes partout.

platform.system() renvoie le nom du système :

Système Valeur renvoyée
Linux "Linux"
macOS "Darwin"
Windows "Windows"

Exemple classique : ping envoie un seul paquet avec -c 1 sous Linux et macOS, mais avec -n 1 sous Windows.

import platform

if platform.system() == "Windows":
    option = "-n"
else:
    option = "-c"
subprocess.run(["ping", option, "1", "web-1"])

shutil.which("git") cherche la commande comme le ferait le shell. Il renvoie son chemin (par exemple /usr/bin/git), ou None si elle n’existe pas. Il ne lève jamais d’exception.

import shutil

if shutil.which("git") is None:
    print("git n'est pas installé")

Pourquoi vérifier avant ? Parce que lancer une commande absente avec subprocess.run lève FileNotFoundError. Avec which, tu peux afficher un message clair à la place d’un traceback.

À retenir

  • subprocess.run(["ping", "-c", "1", "web-1"]) : toujours une liste, un élément par argument. Jamais shell=True (injection de commande).
  • r.returncode : 0 = succès. Sans check, un échec ne lève rien.
  • capture_output=True, text=True remplit r.stdout et r.stderr avec du texte. Sans capture, ils valent None.
  • check=True lève CalledProcessError si le code n’est pas 0.
  • timeout=5 lève TimeoutExpired si la commande dépasse 5 secondes.
  • platform.system() donne "Linux", "Darwin" ou "Windows" ; shutil.which("git") donne un chemin, ou None si la commande n’existe pas.

Entraînement

12 questions, dont 4 à compléter. Tu réponds, on passe à la suivante. La note arrive à la fin.

Cartes · 8

Réviser les cartes ici

Espace : retourner