Quand un script Python doit lancer `git`, `npm`, `ffmpeg` ou un utilitaire interne, tout se joue dans la manière d’appeler le processus externe. La méthode `subprocess.run()` couvre l’essentiel des besoins courants: exécuter une commande, récupérer sa sortie, vérifier le code de retour et poser une limite de temps. Je vais aller droit au point utile: comment l’utiliser proprement, quand éviter le shell et où se cachent les pièges de sécurité et de portabilité.
Les points à retenir avant de lancer une commande externe
- La documentation Python actuelle recommande `run()` pour les cas simples, et réserve `Popen` aux usages plus avancés.
- Passer les arguments sous forme de liste est généralement le choix le plus sûr et le plus prévisible.
- `capture_output=True` récupère `stdout` et `stderr`, mais ne se combine pas avec `stdout=` ou `stderr=`.
- Avec `check=True`, un code de sortie non nul déclenche une exception au lieu de passer silencieusement.
- `shell=True` n’est utile que si tu as réellement besoin des fonctions du shell, comme les pipes ou l’expansion des jokers.
- Si la commande produit beaucoup de sortie ou doit rester interactive, il faut souvent passer à `Popen` ou à `asyncio`.
À quoi sert l’exécution de commandes externes en Python
Le module `subprocess` sert à créer un nouveau processus, lui transmettre des arguments, récupérer ses flux standards et lire son code de retour. La documentation Python actuelle recommande `run()` pour tous les cas qu’il peut couvrir, ce qui en fait le bon point de départ dans la majorité des scripts d’automatisation, des outils de déploiement et des tâches de build.
Concrètement, je l’utilise quand Python doit orchestrer un outil déjà existant plutôt que réimplémenter sa logique. Par exemple: lancer des tests avec `pytest`, vérifier l’état d’un dépôt Git, déclencher un build frontend, appeler un binaire de traitement d’images ou exécuter une commande d’administration sur un serveur. C’est typiquement le genre de besoin qu’on rencontre dans un backend, une chaîne CI/CD ou un script de maintenance.
Le vrai intérêt de cette approche, c’est le contrôle. Je peux choisir le répertoire de travail, l’environnement transmis au processus enfant, la manière de récupérer la sortie et le comportement en cas d’échec. C’est plus propre qu’un appel système brut, et bien plus lisible qu’une chaîne de shell bricolée à la main. Une fois ce cadre posé, le sujet devient surtout la bonne forme des arguments.
Passer les arguments correctement
Mon réflexe est simple: je passe presque toujours une liste d’arguments. C’est la forme la plus robuste, parce que Python gère lui-même l’échappement et le découpage des arguments. Une chaîne unique n’a de sens que dans des cas précis, notamment lorsque le shell est volontairement impliqué ou quand on ne passe qu’un nom de programme sans argument dans certains contextes POSIX.
| Forme | Quand l’utiliser | Ce que ça apporte | Ce qu’il faut surveiller |
|---|---|---|---|
["git", "status", "--short"] |
Cas standard | Arguments sûrs et prévisibles | Rien de spécial, c’est la forme à privilégier |
"git status --short" avec shell=True
|
Quand le shell est indispensable | Pipes, redirections, expansion de jokers | Injection de commandes si la chaîne est mal construite |
"git" seul |
Cas limité sur POSIX | Appel minimal | Peu utile dès qu’il faut des arguments |
Quand j’ai une commande tapée par un humain dans un outil interne, j’évite de la transmettre telle quelle. Je la découpe avec `shlex.split()` pour obtenir une séquence correcte. Et si je dois relancer le Python courant, je passe plutôt `sys.executable` avec `-m`, par exemple pour appeler un module ou relancer un environnement virtuel sans ambiguïté.
import sys
from subprocess import run
run([sys.executable, "-m", "pip", "install", "requests"], check=True)
Le principe de fond est simple: je laisse Python faire le travail de séparation des arguments, au lieu de faire confiance à ma propre chaîne de caractères. C’est là que l’on évite la majorité des surprises, et cela prépare bien la récupération de sortie et la gestion d’erreurs.
Lire la sortie, vérifier le retour et gérer les erreurs
Quand `run()` termine, il renvoie un objet `CompletedProcess`. Tu y trouves les arguments réellement utilisés, le code de retour et, si tu as capturé les flux, le contenu de `stdout` et `stderr`. Par défaut, la sortie revient en bytes; si tu veux du texte, ajoute `text=True` ou un `encoding`, par exemple `utf-8`.
Je trouve que trois paramètres font la différence dans la vraie vie. D’abord `capture_output=True` pour récupérer `stdout` et `stderr` sans écrire le câblage à la main. Ensuite `check=True` pour faire échouer immédiatement un script si la commande externe retourne un code non nul. Enfin `timeout=` pour éviter qu’un processus bloqué ne fasse attendre tout le reste. Le délai est exprimé en secondes, ce qui suffit généralement pour les scripts d’automatisation.
from subprocess import run, CalledProcessError, TimeoutExpired
try:
result = run(
["git", "status", "--short"],
capture_output=True,
text=True,
check=True,
timeout=5,
)
print(result.stdout)
except TimeoutExpired:
print("La commande a dépassé le délai.")
except CalledProcessError as exc:
print(f"Échec avec le code {exc.returncode}")
print(exc.stderr)
Deux détails méritent d’être retenus. D’une part, `capture_output=True` ne peut pas être combiné avec `stdout=` ou `stderr=`. D’autre part, si tu veux fusionner sortie normale et sortie d’erreur, il faut rediriger `stderr` vers `STDOUT` plutôt que de compter sur `capture_output`. J’utilise aussi `DEVNULL` quand je n’ai pas besoin de la sortie et que je veux simplement éviter qu’elle encombre le script.
Et si la commande peut produire énormément de données, je me méfie: tout capturer en mémoire n’est pas toujours une bonne idée. C’est précisément là que l’on commence à sortir du cas simple, donc la question suivante devient celle de la sécurité et des limites de cette API.

Sécurité et portabilité quand le shell entre en jeu
Je pars d’un principe strict: si je peux éviter `shell=True`, je l’évite. La documentation Python rappelle que le module ne lance pas implicitement un shell; cela permet justement de transmettre sans danger des caractères spéciaux à un programme enfant. Le risque apparaît dès qu’on force le passage par le shell, parce qu’alors le quoting et l’échappement deviennent ma responsabilité.
C’est pour cela que je réserve `shell=True` aux cas où le shell apporte une vraie valeur: pipelines, redirections, expansion de jokers ou commandes intégrées au shell. Sur Windows, cela a du sens surtout pour des commandes du shell comme `dir` ou `copy`. Pour un exécutable normal ou un fichier batch, ce n’est généralement pas nécessaire.
Si je n’ai pas le choix, je fais attention au quoting et je ne mélange jamais des données non fiables avec une chaîne shell construite à la volée. Sur certains systèmes, `shlex.quote()` aide à protéger les fragments à insérer, mais il ne dispense pas d’une conception prudente. La règle pratique est simple: la commande doit rester lisible, bornée et prévisible.
La portabilité demande aussi un peu de discipline. Un chemin absolu réduit les variations entre plateformes, et `shutil.which()` permet de résoudre proprement un binaire dans le `PATH`. De plus, `cwd` et `env` influencent le contexte d’exécution, donc je les fixe explicitement dès que le script dépend d’un répertoire précis ou d’une variable d’environnement particulière. C’est souvent ce petit effort qui évite les bugs “ça marche chez moi” au moment du déploiement.
Savoir quand quitter `run()` pour `Popen` ou `asyncio`
`run()` est excellent pour une commande simple, synchrone, courte et bornée. Dès que j’ai besoin de lire la sortie au fil de l’eau, d’échanger avec le processus, de faire tourner plusieurs commandes en parallèle ou de garder un contrôle fin sur le cycle de vie, je change d’outil. C’est moins une question de préférence qu’une question de forme du problème.
| Besoins | API adaptée | Pourquoi |
|---|---|---|
| Une commande ponctuelle | run() |
Simple, lisible, retour direct |
| Flux à lire progressivement | Popen |
Contrôle fin sur `stdin`, `stdout` et `stderr` |
| Plusieurs processus en parallèle | asyncio.create_subprocess_exec() |
Orchestration non bloquante |
| Dialogue continu avec l’outil |
Popen ou `asyncio` |
Adapté aux échanges prolongés |
Je fais aussi attention au volume de sortie. Avec `Popen`, attendre naïvement un processus qui remplit un pipe peut conduire à un blocage; la bonne méthode est alors d’utiliser `communicate()`. Avec `asyncio`, l’intérêt est différent: je peux surveiller et exécuter plusieurs sous-processus en même temps, ce qui devient très utile pour des tâches de build ou de scraping concurrent. Dès que la sortie devient volumineuse ou interactive, le choix de l’API change.
Le schéma que j’applique pour automatiser sans surprise
Si je devais résumer ma pratique en une règle de travail, ce serait celle-ci: je garde la commande explicite, je passe les arguments en liste, je capture la sortie seulement si elle me sert réellement, et je pose toujours un `timeout` dès que le risque de blocage existe. Pour les scripts de production ou de CI, j’ajoute presque systématiquement `check=True`, parce qu’un échec silencieux coûte plus cher qu’une exception nette.
Voici le modèle que j’utilise le plus souvent pour une tâche d’automatisation propre et lisible:
import os
import sys
from shutil import which
from subprocess import run
program = which("git")
if program is None:
raise RuntimeError("Git introuvable")
env = os.environ.copy()
env["PYTHONUTF8"] = "1"
run([program, "status", "--short"], check=True, text=True, env=env)
Ce petit squelette montre l’essentiel: un chemin résolu, un environnement maîtrisé et une exécution qui échoue clairement si quelque chose ne va pas. Dans un projet web, c’est exactement ce genre de base qui permet d’appeler des outils externes sans transformer le script en zone grise. Si tu gardes cette discipline, l’API reste simple à lire et solide à maintenir.