Правильный способ запускать shell-команды из Python
Python часто берут, чтобы автоматизировать всё подряд, в том числе задачи системного администрирования и всё, где нужно запускать другие программы или общаться с операционной системой. Способов сделать это в Python, впрочем, много, и большинство из них, пожалуй, плохие.
Поэтому в этой статье разберём все варианты запуска чужих процессов, которые у вас есть в Python: плохие, хорошие и, самое главное, правильный способ это делать.
Варианты
В Python встроенных способов стыковаться с другими программами неприлично много. Какие-то лучше, какие-то хуже, и, честно говоря, мне не нравится ни один. Быстро пробежимся по каждому и посмотрим, когда (если вообще) есть смысл брать конкретный модуль.
Штатные инструменты
Общее правило: берите штатные функции, а не зовите напрямую другие программы или команды ОС. Сначала посмотрим, что для этого есть в самом Python:
- pathlib - если нужно создать или удалить файл или каталог, проверить, что файл есть, поменять права и так далее, запускать системные команды совершенно незачем. Берите pathlib, там есть всё нужное. Когда начнёте им пользоваться, ещё и поймёте, что про другие модули вроде glob или os.path можно забыть.
- tempfile - то же самое: нужен временный файл, берите модуль tempfile, не возитесь с /tmp вручную.
- shutil - pathlib закрывает большую часть файловых задач в Python, но если нужно, например, скопировать, переместить, сделать chown, which или собрать архив, тогда вам в shutil.
- signal - если нужны обработчики сигналов.
- syslog - интерфейс к Unix-овскому syslog.
Если ничего из этих встроенных вариантов задачу не закрывает, только тогда имеет смысл лезть в ОС или в другие программы напрямую...
Модуль os
Начнём с худшего варианта, модуля os. Он даёт низкоуровневые функции для общения с ОС, и многие из них уже заменены функциями из других модулей.
Если вам просто нужно вызвать какую-то другую программу, вы могли бы взять функцию os.system. Но не стоит. Пример даже приводить не хочу: вам ей просто не надо пользоваться.
Первым выбором os быть не должен, но пара функций вам может пригодиться:
import os
print(os.getenv('PATH'))
# /home/martin/.local/bin:/usr/local/sbin:/usr/local/bin:...
print(os.uname())
# posix.uname_result(sysname='Linux', nodename='...', release='...', version='...', machine='x86_64')
print(os.times())
# posix.times_result(user=0.01, system=0.0, children_user=0.0, children_system=0.0, elapsed=1740.63)
print(os.cpu_count())
# 16
print(os.getloadavg())
# (2.021484375, 2.35595703125, 2.04052734375)
old_umask = os.umask(0o022)
# Делаем что-то с файлами...
os.umask(old_umask) # вернуть старый umask
# Только если нужны случайные числа получше, чем псевдослучайные из модуля random:
from base64 import b64encode
random_bytes = os.urandom(64)
print(b64encode(random_bytes).decode('utf-8'))
# C2F3kHjdzxcP7461ETRj/YZredUf+NH...hxz9MXXHJNfo5nXVH7e5olqLwhahqFCe/mzLQ==
Кроме показанного выше, есть ещё функции для создания fd (файловых дескрипторов), пайпов, открытия PTY, chroot, chmod, mkdir, kill, stat. Но я бы вас от них отговаривал: есть варианты лучше. В документации даже есть раздел, где показано, как заменить os на модуль subprocess. Так что про os.popen, os.spawn и os.system даже не думайте.
И то же самое касается файлов и путей через модуль os - пожалуйста, не надо. Вот целый раздел о том, как брать pathlib вместо os.path и остальных функций для путей.
Большая часть оставшихся функций в модуле os - прямой интерфейс к API ОС (или языка C). Например, os.dup, os.splice, os.mkfifo, os.execv, os.fork и так далее. Если вам нужно пользоваться всем этим, я не уверен, что Python для такой задачи подходящий язык...
Модуль subprocess
Второй вариант, чуть получше, - модуль subprocess:
import subprocess
p = subprocess.run('ls -l', shell=True, check=True, capture_output=True, encoding='utf-8')
# 'p' - экземпляр 'CompletedProcess(args='ls -la', returncode=0)'
print(f'Command {p.args} exited with {p.returncode} code, output: \n{p.stdout}')
# Command ls -la exited with 0 code
# total 36
# drwxrwxr-x 2 martin martin 4096 apr 22 12:53 .
# drwxrwxr-x 42 martin martin 20480 apr 22 11:01 ..
# ...
Как сказано в документации:
Рекомендуемый подход к вызову подпроцессов - использовать функцию run() для всех случаев, которые она может обработать.
В большинстве случаев вам хватит subprocess.run, если передать kwargs и поменять поведение. Например, shell=True позволяет отдать команду одной строкой, check=True заставляет бросать исключение, если код выхода не 0, а capture_output=True заполняет атрибут stdout.
Хотя subprocess.run() - рекомендуемый способ запускать процессы, в модуле есть и другие варианты (ненужные, устаревшие): call, check_call, check_output, getstatusoutput, getoutput. Обычно вам нужны только run и Popen:
with subprocess.Popen(['ls', '-la'], stdout=subprocess.PIPE, encoding='utf-8') as process:
# process.wait(timeout=5) # Возвращает только код: 0
outs, errs = process.communicate(timeout=5)
print(f'Command {process.args} exited with {process.returncode} code, output: \n{outs}')
# Пайпы
import shlex
ls = shlex.split('ls -la')
awk = shlex.split("awk '{print $9}'")
ls_process = subprocess.Popen(ls, stdout=subprocess.PIPE)
awk_process = subprocess.Popen(awk, stdin=ls_process.stdout, stdout=subprocess.PIPE, encoding='utf-8')
for line in awk_process.stdout:
print(line.strip())
# .
# ..
# examples.py
# ...
Первый пример выше - это эквивалент уже показанного subprocess.run, только на Popen. Но Popen стоит брать, только когда нужна гибкость, которой у run нет. Во втором примере видно, как прокинуть вывод одной команды в другую и фактически выполнить ls -la | awk '{print $9}'. Ещё мы взяли shlex.split: удобная функция, которая режет строку на список токенов. Их можно отдать в Popen или run и обойтись без shell=True.
С Popen можно дополнительно звать terminate(), kill() и send_signal(), чтобы плотнее общаться с процессом.
В примерах выше мы толком не обрабатывали ошибки, а при запуске чужих процессов сломаться может много чего. Для скриптов попроще, скорее всего, хватит check=True: как только подпроцесс нарвётся на ненулевой код возврата, поднимется CalledProcessError, и программа упадёт быстро и громко, и это хорошо. Если заодно задать аргумент timeout, можно ещё словить TimeoutExpired. Но вообще все исключения модуля subprocess наследуются от SubprocessError, так что если хотите их ловить, достаточно просто следить за SubprocessError.
Правильный способ
Дзен Python говорит:
Должен существовать один-- и желательно только один --очевидный способ сделать это.
Но пока мы видели способов немало, и все они из встроенных модулей Python. Так какой же из них правильный? По-моему... ни один...
Стандартную библиотеку Python я люблю, но одной из её недостающих «батареек», по-моему, остаётся модуль subprocess получше.
Если ловите себя на том, что из Python оркестрируете кучу чужих процессов, хотя бы гляньте на библиотеку sh:
# https://pypi.org/project/sh/
# pip install sh
import sh
# Запустить любую команду из $PATH...
print(sh.ls('-la'))
ls_cmd = sh.Command('ls')
print(ls_cmd('-la')) # Явно
# total 36
# drwxrwxr-x 2 martin martin 4096 apr 8 14:18 .
# drwxrwxr-x 41 martin martin 20480 apr 7 15:23 ..
# -rw-rw-r-- 1 martin martin 30 apr 8 14:18 examples.py
# Если команды нет в PATH:
custom_cmd = sh.Command('/path/to/my/cmd')
custom_cmd('some', 'args')
with sh.contrib.sudo:
# Делаем что-то через sudo...
...
Когда мы вызываем sh.some_command, библиотека sh ищет встроенную команду shell или бинарник с таким именем в вашем $PATH. Если находит такую команду, просто выполняет её за вас. Если команды в $PATH нет, можно создать экземпляр Command и звать уже его. Если нужен sudo, берите контекстный менеджер sudo из модуля contrib. Просто и прямолинейно, да?
Чтобы записать вывод команды в файл, достаточно передать функции аргумент _out:
sh.ip.address(_out='/tmp/ipaddr') # То же, что 'ip address > /tmp/ipaddr'
Пример выше заодно показывает, как звать подкоманды: просто ставьте точки.
И наконец, пайпы (|) тоже можно использовать, через аргумент _in:
print(sh.awk('{print $9}', _in=sh.ls('-la')))
# То же, что "ls -la | awk '{print $9}'"
print(sh.wc('-l', _in=sh.ls('.', '-1')))
# То же, что "ls -1 | wc -l"
Что до обработки ошибок, достаточно следить за исключениями ErrorReturnCode или TimeoutException:
try:
sh.cat('/tmp/doesnt/exist')
except sh.ErrorReturnCode as e:
print(f'Command {e.full_cmd} exited with {e.exit_code}')
# Command /usr/bin/cat /tmp/doesnt/exist exited with 1
curl = sh.curl('https://httpbin.org/delay/5', _bg=True)
try:
curl.wait(timeout=3)
except sh.TimeoutException:
print("Command timed out...")
curl.kill()
Дополнительно: если процесс завершится от сигнала, получите SignalException. Конкретный сигнал можно проверить отдельно, например через SignalException_SIGKILL (или _SIGTERM, _SIGSTOP и так далее).
В библиотеке есть и встроенное логирование, его достаточно включить:
import logging
# Включить логирование по умолчанию:
logging.basicConfig(level=logging.INFO)
sh.ls('-la')
# INFO:sh.command:<Command '/usr/bin/ls -la', pid 1631463>: process started
# Поменять уровень логов:
logging.getLogger('sh').setLevel(logging.DEBUG)
sh.ls('-la')
# INFO:sh.command:<Command '/usr/bin/ls -la', pid 1631661>: process started
# DEBUG:sh.command:<Command '/usr/bin/ls -la'>: starting process
# DEBUG:sh.command.process:<Command '/usr/bin/ls -la'>.<Process 1631666 ['/usr/bin/ls', '-la']>: started process
# ...
Примеров выше хватит на большинство задач. Если лезете во что-то более продвинутое или заковыристое, загляните в туториалы или FAQ в документации библиотеки: там есть дополнительные примеры.
Напоследок
Ещё раз подчеркну: всегда предпочитайте штатные функции Python, а не хватайтесь за системные команды. И всегда предпочитайте сторонние клиентские библиотеки, вроде kubernetes-client или SDK облачного провайдера, прямому запуску CLI. Это, по-моему, верно, даже если вы пришли из сисадминов и shell вам привычнее, чем Python. И последнее: Python - отличный и куда более надёжный язык, чем shell, но если нужно сцепить слишком много чужих программ и команд, может быть, совсем может быть, вам стоит просто написать скрипт на shell.