Прямой ответ
Современный способ вызвать внешнюю команду — subprocess.run(["git", "status"], capture_output=True, text=True, timeout=30, check=True): список аргументов вместо строки (никакого shell=True на пользовательских данных — инъекция команд тривиальна: ; rm -rf в имени файла исполнится шеллом — OWASP command injection), timeout обязателен (без него зависшая утилита вешает ваш процесс навсегда), check=True (ненулевой код возврата → исключение CalledProcessError — иначе провал команды молча течёт дальше как «успех» — классика молчаливых багов), text=True (str вместо bytes) и capture_output (stdout+stderr захвачены). Если шелл всё-таки нужен (пайпы, glob) — не подставлять данные в строку: аргументы через env/файлы либо shlex.quote(). Для потокового чтения вывода (ffmpeg-прогресс, длинные сборки) — Popen с построчным чтением stdout, для пайпов двух процессов — связка Popen(stdout=PIPE → stdin) или всё же шелл на константной строке; а когда задача решаема библиотекой — библиотека предпочтительнее (git — GitPython/pygit2 для сложных сценариев, HTTP — httpx вместо curl — клиенты): меньше парсинга чужого текста, меньше точек отказа. И окружение: env= передавать явно нужное (включая PATH — частая причина «команда не находится» в cron/systemd — планировщики), секреты не в argv (видно в ps — env-дисциплина), а в env или stdin.
run(): рабочая лошадка
subprocess.run покрывает 90% случаев: result = subprocess.run(["ffmpeg", "-i", src, dst], capture_output=True, text=True, timeout=600); полезные поля результата — returncode, stdout, stderr; режимы ошибок: check=True — исключение на ненулевом коде (в обработчике — stderr в лог: except subprocess.CalledProcessError as e: log.error(e.stderr) — половина диагностики внешних команд живёт в stderr, который без capture теряется); timeout — исключение TimeoutExpired (важно: по таймауту run убивает процесс-ребёнок и читает остаток вывода — но только прямого ребёнка; дерево внуков может выжить — см. группы процессов ниже). Аргументы — только списком: ["grep", "-c", pattern, path] — даже если pattern содержит пробелы/спецсимволы — они один аргумент, не интерпретируются; shell=True + строка — когда команда константна и пайп нужен ("grep ERROR app.log | wc -l" — константа, ок; "grep " + user_input — инъекция, никогда). cwd= — рабочая директория (git-команды «в нужном репозитории» — явнее, чем git -C), env= — окружение (полная замена, не дополнение: скопировать os.environ и добавить ключи — иначе PATH пуст), input= — данные в stdin (пароли/данные — не в argv).
Popen: потоки, пайпы, группы процессов
Popen — когда нужно жить во время выполнения команды: построчный прогресс — proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, text=True) и for line in proc.stdout: (обработка вывода по мере появления — ffmpeg-прогресс, инкрементальные логи сборки; proc.wait() в конце обязателен — зомби-процессы при незакрытии; лучше with subprocess.Popen(...) as proc: — контекст-менеджер закрывает/ждёт); дедлок-ловушка: читать stdout до wait() при заполненном PIPE-буфере stderr — ребёнок блокируется на записи, родитель — на чтении не того потока (решение: communicate() для полного вывода, либо stderr=STDOUT слияние, либо отдельный поток на stderr — capture_output в run делает это корректно под капотом); пайп двух программ — p1 = Popen(["cat", big], stdout=PIPE); p2 = Popen(["gzip"], stdin=p1.stdout, stdout=out); p1.stdout.close() — классическая связка; убийство дерева процессов: terminate()/kill() бьёт прямого ребёнка — шелл-обёртка с внуками может оставить их жить (старт-группа: start_new_session=True при запуске + os.killpg(os.getpgid(proc.pid), SIGTERM) — убить всю группу — сценарий «отмена долгой сборки по требованию пользователя»); зомби и fd — wait()/контекст-менеджер, proc.stdout.close() — дисциплина ресурсов.
Практические сценарии и проверки
Обёртка git для служебного скрипта (частая задача — worktree-сценарии автоматизируются именно так): def git(*args, cwd) -> str: r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, timeout=120, check=True); return r.stdout — единая точка таймаутов/кодов/логов stderr; аргументы списком — ветки с любыми символами безопасны. Проверки, которые экономят часы: код возврата всегда проверяется (или check=True) — «скрипт отработал без ошибок» при провалившейся команде внутри — ложное здоровье; stderr логируется при неуспехе — «Command failed: exit 1» без stderr — недиагностируемо; таймауты на всё — включая «гарантированно быстрые» команды (git clone зависает на сети именно «гарантированно быстрого»); пути к бинарникам — не полагаться на PATH в не-интерактивных контекстах (cron/systemd — абсолютный путь или явный env); тестирование обёрток — моки subprocess (мок-паттерны) на юнит-уровне + реальные запуски лёгких команд в интеграции (мок subprocess полностью — риск размокать контракт аргументов); кросс-платформенность — shutil.which("ffmpeg") на старте (понятная ошибка «не установлен» вместо FileNotFoundError из глубин), Windows-специфика (списки работают, shell-строки — зло вдвойне). Антисценарий, который стоит назвать: парсинг человекочитаемого вывода утилиты (df -h глазами-регуляркой) — при наличии машинного формата (df --output=source,pcent / JSON-флаги) — хрупкость на пустом месте; у большинства CLI есть --porcelain/--json — пользоваться (grep/jq-культура — машинные форматы).
FAQ
subprocess.run против os.system и os.popen? os.system — только код возврата, вывод в терминал, строка через шелл (инъекции) — устарел; os.popen — вывод без кода возврата и ошибок — устарел; subprocess.run/Popen — полный контроль (ввод/вывод/коды/таймауты/сигналы) — единственная правильная семья; legacy-код на os.system — мигрировать.
Нужен ли shell=True когда-нибудь?
Да, в узком случае: константная команда с шелл-фичами (пайпы, glob, редиректы) и без внешних данных — "cat access.log | wc -l"; всё, что содержит переменные извне, — списком аргументов; «shell=True потому что команда не запускается списком» — обычно симптом не той команды (шелл-бuiltin вроде cd — не бинарник: cwd= вместо cd).
Как передать большой объём данных в stdin и прочитать много stdout?
proc.communicate(input=data) — корректно для «всё сразу» (внутренне читает параллельно — дедлоков нет; память — вся данных в памяти); для гигабайтов — потоковое: писать в stdin чанками в отдельном потоке/через select, читать stdout инкрементально — communicate() на гигабайтах — OOM-риск; совсем большое — через временный файл/pipe-файл, не через память.
Почему команда работает в терминале, но не из скрипта?
Чек-лист: PATH (не-интерактивное окружение беднее — shutil.which + явный путь), рабочая директория (cwd= явно), окружение (профильные переменные терминала отсутствуют — env= явно), шелл-фичи в строке без shell=True (glob не раскрылся — список аргументов + glob.glob на стороне Python), TTY-зависимость утилиты (прогресс-бары/цвета — некоторые CLI меняют поведение без терминала — флаги --no-color/--batch).
Источники: документация Python subprocess, PEP 324, OWASP Command Injection — ссылки в шапке.