Sh (от англ. shell) является обязательным командным интерпретатором для UNIX-совместимых систем по стандарту POSIX. Однако по возможностям он ограничен, поэтому зачастую вместо него используются более богатые возможностями командные интепретаторы, такие как Bash или Ksh. Ksh обычно используется в операционных системах семейства BSD, в то время как Bash — в операционных системах семейства Linux. Командные интерпретаторы облегчают решение мелких задач, связанных с работой с процессами и файлами. В данной статье будут рассматриваться операционные системы Linux, поэтому речь пойдёт о Bash.
Python, в свою очередь, является полноценным интерпретируемым языком программирования, также он нередко используется для написания скриптов или решения мелких прикладных задач. Современную UNIX-подобную систему сложно представить как без sh, так и без Python, если только это не устройство с минималистичной ОС вроде маршрутизатора. Например, в Ubuntu Oracular пакет python3 удалить не получится хотя бы потому, что от него зависит пакет grub-common, от которого, в свою очередь зависят пакеты grub2-common и, соответственно, grub-pc, то есть непосредственно загрузчик операционной системы. Таким образом, Python 3 можно смело использовать как замену Bash в случае необходимости.
При решении различных задач на уровне ОС или файловой системы может возникнуть вопрос, а какой же из языков, Bash или Python выгодно использовать в том или ином случае? И тут всё будет зависеть от решаемой задачи. Bash выгоден, когда нужно быстро решить каку‑либо простую задачу, связанную с управлением процессами, поиском или изменением файлов. В случае же усложнения логики код на Bash становится слишком громоздким и трудночитаемым (хотя читабельность в первую очередь будет зависеть от самого программиста). Можно, конечно код разбивать на скрипты и функции, делать sh-библиотеки, подключаемые через команду source, но модульными тестами это уже сложно будет покрывать.
Предисловие
Для кого эта статья? Для тех, кто увлекается системным администрированием, знаком с одним из двух языков и хочет разобраться со вторым. Либо же для тех, кто хочет познакомиться с некоторыми особенностями Bash и Python, которые он раньше мог не знать. Для понимания материала требуются базовые навыки работы с командной строкой и знакомство с основами программирования.
Для полной картины, в том числе и по читабльности кода, в статье будет приведено сравнение по возможностям отладки, по синтаксису и по тем или иным случаям использования. Будут приводиться аналогичные друг другу примеры на обоих языка. В коде на Python будут иногда встречаться запятые в конце перечислений, это не ошибки, — такой стиль является хорошей практикой, поскольку при добавлении новых элементов в перечисление позволяет избежать пометки последнего элемента как изменённого.
В статье будет рассматриваться Bash как минимум версии 3.0 и Python как минимум версии 3.7.
Отладка скриптов
Оба языка являются интерпретируемыми, это означает, что в момент исполнения скриптов, интерпретатор знает достаточно много о текущем состоянии исполнения.
Отладка в Bash
Отладка через xtrace
Bash поддерживает опцию xtrace
(-x
), которую можно задать как в командной строке при запуске интерпретатора, так и внутри самого скрипта:
#!/bin/bash
# Указываем, куда необходимо писать логи, открываем файл на запись:
exec 3>/путь/к/файлу/логов
BASH_XTRACEFD=3 # в какой файловый дескриптор выводить отладочную информацию
set -x # включаем отладку
# ... отлаживаемы код ...
set +x # выключаем отладку
Такие логи, например, можно писать и в журнал systemd, если реализуется какой-либо простой сервис:
#!/bin/bash
# Указываем, куда необходимо писать логи:
exec 3> >(systemd-cat --priority=debug)
BASH_XTRACEFD=3 # в какой поток выводить отладочную информацию
set -x # включаем отладку
# ... отлаживаемы код ...
set +x # выключаем отладку
Отладка в Bash будет показывать, какие команды запускаются и с какими аргументами. Если требуется получить текущие значения переменных или код исполняемых функций, то это можно сделать командой set
без аргументов. Однако поскольку вывод команды может быть достаточно большой, то set
подходит для ручной отладки, нежели для логирования по событиям.
Отладка через trap
Другим способом отладки является установка обработчиков на запуск команд с помощь команды trap
на специальную «ловушку» DEBUG
. Запускаемые команды могут быть получены через встроенную переменную BASH_COMMAND
. Однако код возврата по этому обработчику получить не получится, поскольку он запускается до вызова самой комнады.
trap 'echo "+ ${BASH_COMMAND}"' DEBUG
Но более полезным будет перехват ошибок и вывод команды и номера строки, на которых ошибка произошла. Для наследования этого перехвата функциями понадобится ещё установить опцию functrace
:
set -o functrace
trap 'echo "+ строка ${LINENO}: ${BASH_COMMAND} -> $?"' ERR
# Тестируем:
ls "${PWD}"
ls unknown_file
Отладка в Python
Отладка через pdb
В Python богатые средства отладки и логирования. По части отладки в Python есть модуль pdb. Можно запускать скрипт с включенной из консоли отладкой, в таком случае при исключительных ситуациях будет включаться режим отладки:
python3 -m pdb my_script.py
Непосредственно в коде можно устанавливать точки останова с помощью встроенной функции breakpoint()
.
#!/usr/bin/python3
import os
breakpoint()
# Теперь можно попробовать, например, команду source os:
# (Pdb) source os
Сам язык является объектно‑ориентированным, в нём всё является объектами. Посмотреть, какие методы есть у объекта, можно с помощью команды dir()
. Так, через dir(1)
можно узнать, какие методы есть у объекта 1
. Пример вызова одного из таких методов: (1).bit_length()
. Во многих случаях это помогает разобраться с возникающими вопросами даже без необходимости чтения документации. В режиме отладки также можно использовать команды dir()
для получения информации об объектах и print()
для получения значений переменных.
Логирование через модуль logging
Python предоставляет модуль logging, который позволяет записывать в лог отладочную информацию с указанием уровней логирования и источника логов. В целом логирование выглядит примерно так:
import logging
logging.basicConfig(
filename = 'myscript.log',
level = logging.DEBUG, # выводить уровни DEBUG, INFO, WARNING, ERROR и CRITICAL
)
logger = logging.getLogger('MyApp')
logger.debug('Some debug information')
logger.error('Some error')
Сравнение семантики Bash и Python
Переменные и типы данных
Примитивные типы данных
В Bash все переменные строковые, но строковые переменные можно использовать и как числа. Для получения результата арифметических вычислений применяется синтаксическая конструкция $(( выражение ))
.
str_var='some_value' # строка, массив символов
int_var=1234 # строка "1234", но можно использовать в вычислениях
int_var=$(( 1 + (int_var - 44) / 111 - 77 )) # строка: "-66"
str_var = 'some_value' # класс str
int_var = 1234 # класс int
int_var = 1 + (int_var - 44) // 111 - 77 # -66, класс int
Вещественные же числа в Bash не поддерживаются. И это логично, ведь если потребовалось использовать вещественные числа в скриптах командной строки, то явно делается что‑то не на том уровне или не на том языке программирования. Тем не менее, вещественные числа поддерживаются в Ksh.
Форматирование строк
И Bash и Python поддерживают подстановку значения переменных в форматированные строки. В Bash форматируемыми строками являются строки, заключённые в кавычки, а в Python — строки с префиксом f
.
Также оба языка поддерживают C-подобный стиль вывода форматированных строк. В Bash таким образом можно даже форматировать вещественные числа, хотя сам язык их и не поддерживает (разделитель десятичной части определяется локалью).
var1='Some string'
var2=0,5
echo "Переменная 1: ${var1}, переменная 2: ${var2}"
# Переменная 1: Some string, переменная 2: 0,5
# Без текущей локали
LANG=C
printf 'Строка: %s, число: %d, вещественное число: %f.n'
'str' '1234' '0.1'
# С текущей локалью
printf 'Строка: %s, число: %d, вещественное число: %f.n'
'str' '1234' '0,1'
# Строка: str, число: 1234, вещественное число: 0,100000.
var1 = 'Somstr_var = 'some_value'
int_var = 1234e string'
var2 = 0.5
print(f"Переменная 1: {var1}, переменная 2: {var2}")
# Переменная 1: Some string, переменная 2: 0.5
# Без текущей локали:
print('Строка: %s, число: %d, вещественное число: %f.'
% ('str', 1234, 0.1))
# Строка: str, число: 1234, вещественное число: 0.100000.
# С текущей локалью:
import locale
locale.setlocale('') # применяем текущую локаль
print(locale.format_string('Строка: %s, число: %d, вещественное число: %f.',
('str', 1234, 0.1)))
# Строка: str, число: 1234, вещественное число: 0,100000.
Можно заметить отличие в плане локали — в Python функция print()
игнорирует локаль. Если требуется вывод значений с учётом локали, то необходимо использовать функцию locale.format_string()
.
Массивы
В Bash массивы — это по сути текст, разделённый пробелами (по умолчанию). При этом синтаксис очень специфичен, например, для копирования массива (через @
) получение всех его элементов надо заключать в кавычки, иначе любые пробелы в самих элементах приведут к разделению элемента на части. Но в целом работа с массивами между языками схожа в простых случаях:
arr=( 'First item' 'Second item' 'Third item' )
echo "${arr[0]}" "${arr[1]}" "${arr[2]}"
arr_copy="${arr[@]}" # копирование массива, кавычки обязательны
arr[0]=1
arr[1]=2
arr[2]=3
echo "${arr[@]}"
echo "${arr_copy[0]}" "${arr_copy[1]}" "${arr_copy[2]}"
arr = [ 'First', 'Second', 'Third' ]
print(arr[0], arr[1], arr[2])
arr_copy = arr.copy() # но можно делать и как в Bash: [ *arr ]
arr[0] = 1
arr[1] = 2
arr[2] = 3
print(*arr)
print(arr_copy[0], arr_copy[1], arr_copy[2])
Оператор *
в Python выполняет распаковку списков, словарей, итераторов и т. п. То есть элементы массива как будто перечисляются через запятую в качестве аргументов.
Ассоциативные массивы
Ассоциативные массивы Bash тоже поддерживает (в отличие от Sh), но возможности по работе с ними ограничены. В Python же ассоциативные массивы называются словарями, и язык предоставляет очень богатые возможности для работы с ними.
declare -A assoc_array=(
[name1]='Значение 1'
[name2]='Значение 2'
[name3]='Значение 3'
)
# Присвоение значения по ключу:
assoc_array['name4']='Значение 4' # присвоение значения
# Поэлементный доступ:
echo "${assoc_array['name1']}"
"${assoc_array['name2']}"
"${assoc_array['name3']}"
"${assoc_array['name4']}"
echo "${!assoc_array[@]}" # вывести все ключи
echo "${assoc_array[@]}" # вывести все значения
# Обход всех элементов
for key in "${!assoc_array[@]}"; do
echo "Key: ${key}"
echo "Value: ${assoc_array[$key]}"
done
assoc_array = {
'name1': 'Значение 1',
'name2': 'Значение 2',
'name3': 'Значение 3',
}
# Присвоение значения по ключу:
assoc_array['name4'] = 'Значение 4'
# Поэлементный доступ
print(
assoc_array['name1'],
assoc_array['name2'],
assoc_array['name3'],
assoc_array['name4']
)
print(*assoc_array) # вывести все ключи
print(*assoc_array.values()) # вывести все значения
for key, value in assoc_array.items():
print(f"Key: {key}")
print(f"Value: {value}")
Подключение модулей
В Bash как таковые модули отсутствуют. Но в нём можно исполнить скрипт в текущем интерпретаторе через команду source
. Фактически, это аналог импортирования модулей, поскольку все функции подключаемого скрипта становятся доступны в пространстве текущего интерпретатора. В Python же есть полноценная поддержка модулей с возможностью их импорта. При этом стандартная библиотека Python содержит большое количество модулей для самых разных сценариев использования. Фактически, то, что в Bash реализуется сторонними утилитами командной строки, в Python может быть доступно в виде модулей стандартной библиотеки (а если нет, то можно установить дополнительные библиотеки).
# Подключаем файл mylib.sh с какими-либо функциями:
source mylib.sh
# Посмотрим список доступных функций (вообще всех]):
declare -F
# Подключаем модуль mylib.py или mylib.pyc:
import mylib
# Посмотрим список доступных объектов модуля mylib:
print(dir(mylib))
Ветвления и циклы
Условный оператор
В Bash условия работают по двум принципам: либо в качестве условия подаётся команда, и проверяется её код возврата, либо используются встроенные в Bash двойные квадратные или двойные круглые скобки. При этом в случае кода возврата 0 является истиной (всё хорошо), а в случае двойных круглых скобок всё наоборот, проверяется результат арифметического выражения, где 0 — ложь.
В Python же стандартный для языков программирования подход: False
, 0
, ''
, []
, set()
, {}
— всё это приравнивается к False
. Непустые ненулевые значения — к True
.
if [[ "${PWD}" == "${HOME}" ]]; then
echo 'Текущий каталог: ~'
elif [[ "${PWD}" == "${HOME}"* ]]; then
echo "Текущий каталог: ~${PWD#${HOME}}"
else
echo "Текущий каталог: ${PWD}"
fi
if (( UID < 1000 )); then
echo "Вы вошли под системным пользователем. Пожалуйста, войдите под собой."
fi
import os
curr_dir = os.environ['PWD']
home_dir = os.environ['HOME']
if curr_dir == home_dir:
print('Текущий каталог: ~')
elif curr_dir.startswith(home_dir):
print('Текущий каталог: ~' + curr_dir[len(home_dir):])
else:
print(f"Текущий каталог: {curr_dir}")
if os.environ['UID'] < 1000:
print('Вы вошли под системным пользователем. Пожалуйста, войдите под собой.')
Циклы
Оба языка поддерживают циклы for
и while
.
Цикл с обходом элементов
В обоих языках цикл for
поддерживает обход элементов через оператор in
. В Bash обоходятся элементы массива или элементы строки, разделённые разделителями, записанными в переменной IFS
(по умолчанию пробел, табуляция и перевод строки). В Python оператор in
позволяет обходить любые итерируемые объекты, например списки, множества, кортежи и словари и более безопасен в работе.
# Перекодирование текстовых файлов из CP1251 в UTF-8
for filename in *.txt; do
tmp_file=`mktemp`
iconv -f CP1251 -t UTF-8 "${filename}" -o "${tmp_file}"
mv "${tmp_file}" "${filename}"
done
import glob
from pathlib import Path
# Перекодирование текстовых файлов из CP1251 в UTF-8
for filename in glob.glob('*.txt'):
file = Path(filename)
text = file.read_text(encoding='cp1251')
file.write_text(text, encoding='utf8')
Цикл for со счётчиком
Цикл со счётчиком в Bash выглядит непривычно, используется форма для арифметических вычислений ((инициализация; условия; действия после итерации))
.
# Получаем список всех локально прописанных хостов:
mapfile -t lines < <(grep -P -v '(^s*$|^s*#)' /etc/hosts)
# Выводим список с нумерацией:
for ((i = 0; i < "${#lines[@]}"; i += 1)); do
echo "$((i + 1)). ${lines[$i]}"
done
from pathlib import Path
import re
def is_host_line(s):
return not re.match(r'(^s*$|^s*#)', s)
lines = list(filter(is_host_line, Path('/etc/hosts').read_text().splitlines()))
for i in range(0, len(lines)):
print(f"{i + 1}. {lines[i]}")
Функции
Как и в обычных языках, в Bash поддерживаются функции. По своей сути функции в Bash похожи на отдельные скрипты — им также можно передавать аргументы как обычным скриптам, и они возвращают код возврата. Но, в отличие от Python, они не могут вернуть результат, отличный от кода возврата. Однако можно возвращать текст через поток вывода.
some_function()
{
echo "Script: $0."
echo "Function: ${FUNCNAME}."
echo "Function arguments:"
for arg in "$@"; do
echo "${arg}"
done
return 0
}
some_function Раз Два Три Четыре Пять
echo $? # Код возврата
import inspect
def some_function_is_ok(*args):
try: # Если вдруг запустили из интерпретатора
script_name = __file__
except:
script_name = ''
print('Script: ' + script_name)
print('Function: ' + inspect.getframeinfo(inspect.currentframe()).function)
print('Function arguments:')
print(*args, sep='n')
return True
result = some_function_is_ok('Раз', 'Два', 'Три', 'Четыре', 'Пять')
print(result) # True
Потоки ввода, вывода и ошибок
Поток ввода служит для получения информации процессом, а в поток вывода информация выводится. Почему потоки, а не обычные переменные? Потому что в потоках информация может обрабатываться по мере её появления. Поскольку информация из потока вывода может проходить дальнейшую обработку, сообщения об ошибках эту информацию могут сломать. Поэтому ошибки выводятся в отдельный поток ошибок. Впрочем, при запуске команды в интерактивном режиме эти потоки перемешиваются. Поскольку это потоки, их можно перенаправлять, например, в файл. Или наоборот, считывать файл в поток ввода. В Bash поток ввода имеет номер 0, поток вывода — 1, поток ошибок — 2. Если в операторе перенаправления в файл не указан номер потока, то перенаправляется поток вывода.
Запись в файл
Запись в файл в Bash осуществляется с помощью оператора >
, который перенаправляет вывод команды в указанный после неё файл. В Python писать текстовые файлы можно с помощью модуля pathlib
, либо стандартными средствами, — посредством открытия файла через функцию open()
. Последний вариант сложнее, но хорошо знаком программистам.
# Очиститьтекстовый файл, перенаправив в него вывод пустой строки:
echo -n > some_text_file.txt
# Записать в файл строку, затерев его:
echo 'Строка 1' > some_other_text_file.txt
# Добавить строку в файл строку:
echo 'Строка 2' >> some_other_text_file.txt
from pathlib import Path
# Перезаписываем файл пустой строкой (делаем его пустым):
Path('some_text_file.txt').write_text('')
# Перезаписываем файл строкой:
Path('some_other_text_file.txt').write_text('Строка 1')
# Открываем файл на дозапись (a):
with open('some_other_text_file.txt', 'a') as fd:
print('Строка 2', file=fd)
Запись в файл многострочного текста
Для многострочного текста в Bash есть специальный формат heredoc (произвольная метка после <<<
, повтор которой с новой строки будет означать конец текста), который позволяет перенаправить произвольный текст в поток ввода команды, а уже из команды его можно перенаправить в файл (и тут уже без внешней команды cat
не обойтись). С перенаправлением же содержимого файла в процесс намного проще.
# Перенаправление многострочного текста в файл на дозапись:
cat <<<EOF >> some_other_text_file.txt
Строка 3
Строка 4
Строка 5
EOF
# Перенаправляет содержимое файла в команду cat:
cat < some_other_text_file.txt
# Открываем файл на дозапись (w+):
with open('some_other_text_file.txt', 'w+') as fd:
print("""Строка 3
Строка 4
Строка 5""", file=fd)
# Открываем файл на чтение (r):
with open('some_other_text_file.txt', 'r') as fd:
# Выводим построчно содержимое файла:
for line in fd:
print(line)
# Можно и fd.read(), то тогда файл будет считан в память целиком.
Чтение из файла
В Bash чтение из файла осуществляется через знак <
. В Python можно читать стандартным способом через open()
, а можно и простым — через Path(...).read_text()
:
cat < some_other_text_file.txt
import pathlib
print(Path('some_other_text_file.txt').read_text())
Перенаправление потоков
Перенаправлять потоки можно не только в файл или в процесс, но и в другой поток.
error()
{
# Перенаправляем поток вывода и поток ошибок в поток ошибок (2).
>&2 echo "$@"
}
error 'Произошла ошибка.'
print('Произошла ошибка.', file=sys.stderr)
В простых случаях перенаправление в файл или из файла в Bash выглядит намного понятнее и проще, чем запись в файл или чтение из него в Python. Однако в сложных случаях код на Bash будет менее понятным и более сложным для анализа.
Выполнение внешних команд
Запуск внешних команд в Python более громоздкий, нежели в Bash. Хотя, конечно, есть простые функции subprocess.getoutput()
и subprocess.getstatusoutput()
, но в них теряется преимущество Python в плане передаче каждого отдельного аргумента как элемента списка.
Получение вывода команды
Если из команды требуется просто получить текст и мы уверены, что это всегда будет работать, то это можно сделать следующим образом:
cmd_path="`which ls`" # косые кавычки выполняют команду и возвращают её вывод
echo "${cmd_path}" # вывести путь к команде
import subprocess
cmd_path = subprocess.getoutput("which ls").rstrip('n')
print(cmd_path) # выводим путь к команде ls
Но само по себе получение вывода акоманды через косые кавычки в Bash будет неправильным, если требуется получить массив строк. В Python subprocess.getoutput()
принимает командную строку, а не массив аргументов, что несёт некоторые риски при подстановке значений. И оба варианта не игнорируют код возврата исполняемой команды.
Запуск же утилиты в Python для получения какого‑либо списка в переменную займёт намного больше кода, нежели в Bash, хотя код в Python будет намного понятнее и проще:
mapfile -t root_files < <(ls /) # помещаем в root_files список файлов из /
echo "${root_files[@]}" # Вывести список файлов
import subprocess
result = subprocess.run(
['ls', '/'], # мы уверены, что такая команда есть
capture_output = True, # получить вывод команды
text = True, # интерпретировать ввод и вывод как текст
)
root_files = result.stdout.splitlines() # получаем строки из вывода
print(*root_files, sep='n') # выводим по файлу на строку
Получение и обработка кода возврата
С полноценной обработкой ошибок всё ещё сложнее, добавляются проверки, усложняющие код:
root_files="`ls /some/path`" # Запуск команды в косых кавычках
if [[ $? != 0 ]]; then
exit $?
fi
echo "${root_files[@]}" # Вывести список файлов
import subprocess
import sys
result = subprocess.run(
['ls', '/some/path'],
capture_stdout = True, # получить вывод команды
text = True, # интерпретировать ввод и вывод как текст
shell = True, # чтобы получить код возврата, а не исключение, если команды нет
)
if result.returncode != 0:
sys.exit(result.returncode)
root_files = result.stdout.split('n') # получаем строки из вывода
del root_files[-1] # последняя строка будет пустой из-за n в конце, удаляем
print(*root_files, sep='n') # выводим по файлу на строку
Выполнение команды с одним лишь получением кода возврата чуть проще:
any_command any_arg1 any_arg2
exit_code=$? # получаем код возврата предыдущей команды
if [[ $exit_code != 0 ]]; then
exit 1
fi
import subprocess
import sys
result = subprocess.run(
[
'any_command',
'any_arg1',
'any_arg2',
],
shell = True, # чтобы получить код ошибки несуществующего процесса, а не исключение
)
if result.returncode != 0:
sys.exit(1)
Исключения вместо обработки кода возврата
Но всё становится ещё проще, если включен режим выхода из скрипта по любой ошибке. В Python такой подход применяется по умолчанию, ошибки не требуется проверять вручную, функция может выбросить исключение и аварийно завершить работу процесса.
set -o errexit # аварийное завершение по ошибкам команда
set -o pipefail # весь пайплайн завершается с ошибкой, если ошибка внутри пайплайна
critical_command any_arg1 any_arg2
import subprocess
subprocess.run(
[
'critical_command',
'any_arg1',
'any_arg2',
],
check = True, # выбросить исключение при ненулевом коде возврата
)
В отдельных случаях исключения можно перехватить и обработать. В Python это осуществляется через оператор try
. В Bash такие перехваты осуществляются через обычный оператор if
.
set -o errexit # аварийное завершение по ошибкам команда
set -o pipefail # весь пайплайн завершается с ошибкой, если ошибка внутри пайплайна
if any_command any_arg1 any_arg2; then
do_something_else any_arg1 any_arg2
fi
import subprocess
try:
subprocess.run(
[
'critical_command',
'any_arg1',
'any_arg2',
],
check = True, # выбросить исключение при ненулевом коде возврата
)
except:
subprocess.run(
[
'do_something_else',
'any_arg1',
'any_arg2',
],
check = True, # выбросить исключение при ненулевом коде возврата
)
В высокоуровневых языках стараются обработку ошибок через исключения делать. Код получается проще и понятнее, а значит меньше шансов допустить ошибку, да и рецензирование становится дешевле. Хотя иногда такие проверки выглядят более громоздкими, чем обычная проверка кода возврата. Использовать ли такой стиль обработки ошибок во многом зависит от того, будут ли такие проверки на исключения частыми либо же будут в исключительных случаях.
Построение конвейеров
В Bash конвейеры являются обычной практикой и в самом языке есть синтаксис для создания конвейеров. Поскольку Python не является командным интерпретатором, в нём это делается чуть более громоздко, через модуль subprocess
.
ls | grep -v '.txt$' | grep 'build'
import subprocess
p1 = subprocess.Popen(
['ls'],
stdout = subprocess.PIPE, # для передачи вывода в следующую команду
text = True,
)
p2 = subprocess.Popen(
[
'grep',
'-v',
'\.txt$'
],
stdin = p1.stdout, # создаём конвейер
stdout = subprocess.PIPE, # для передачи вывода в следующую команду
text = True,
)
p3 = subprocess.Popen(
[
'grep',
'build',
],
stdin = p2.stdout, # создаём конвейер
stdout = subprocess.PIPE, # уже для чтения из текущего процесса
text = True,
)
for line in p3.stdout: # читаем построчно по мере поступления данных
print(line, end='') # каждая строка уже оканчивается n
Конвейеры с параллельной обработкой данных
В Bash конвейеры можно создавать как между командами, так и между командами и блоками интерпретатора. Например, можно перенаправить конвейер в цикл построчного чтения. В Python же обработка данных из параллельно запущенного процесса тоже ведётся простым построчным чтением их потока вывода процесса.
# Получить список файлов, в которых содержится какой-либо текст:
find . -name '*.txt'
| while read line; do # поочерёдно получаем пути к файлам
if [[ "${line}" == *'text'* ]]; then # вхождение подстроки в строку
echo "${line}"
fi
done
import subprocess
p = subprocess.Popen(
[
'find',
'.',
'-name',
'*.txt'
],
stdout=subprocess.PIPE,
text=True,
)
while True:
line = p.stdout.readline().rstrip('n') # на конце всегда есть n
if not line:
break
if 'text' in line: # вхождение подстроки в строку
print(line)
Параллельное исполнение процессов с ожиданием их завершения
В Bash запуск процесса в фоновом режиме поддерживается на уровне синтаксиса языка (оператор &
), при этом можно запускать как отдельные команды в фоне, так и части интерпретатора (например, функции или циклы). Но на таком уровне сложности код зачастую будет более простым и понятным, если он написан на Python, к тому же стандартная библиотека предоставляет возможности, которые на уровне командного интерпретатора реализуются сторонними утилитами, которые необходимо учитывать в качестве зависимостей.
unalias -a # на случай, если кто-то будет копировать прямо в терминал
get_size_by_url()
{
url="$1"
# Размер файла получим из поля Content-Length заголовков ответа на запрос HEAD
curl --head --silent --location "${url}"
| while read -r line; do
# Ищем размер в заголовках с помощью регулярного выражения
if [[ "${line}" =~ ^Content-Length:[[:space:]]*(.+)[[:space:]]+$ ]]; then
echo -n "${BASH_REMATCH[1]}" ## 1 соответствует первой открывающейся скобке
return 0
fi
done
}
download_range()
{
url="$1"
start=$2
end=$3
output_file="$4"
((curr_size = end - start + 1))
curl
--silent
--show-error
--range "${start}-${end}"
"${url}"
--output -
| dd
of="${output_file}"
oflag=seek_bytes
seek="${start}"
conv=notrunc
}
download_url()
{
url="$1"
output_file="$2"
((file_size = $(get_size "${url}")))
# Заранее выделяем место на диске под файл:
fallocate -l "${file_size}" "${output_file}"
range_size=10485760 # 10 МиБ
# Делим на части по максимум 100 МиБ:
((ranges_count = (file_size + range_size - 1) / range_size))
declare -a pids ## Будем сохранять все идентификаторы процессов
for ((i = 0; i < ranges_count; i += 1)); do
((start = i * range_size))
((end = (i + 1) * range_size - 1))
if ((end >= file_size)); then
((end = file_size - 1))
fi
# Запускаем загрузку в фоновом режиме:
download_range "${url}" $start $end "${output_file}" &
pids[$i]=$! # запоминаем PID фонового процесса
done
wait "${pids[@]}" # ждём завершения процессов
}
import requests
from multiprocessing import Process
import os
def get_size_by_url(url):
response = requests.head(url)
return int(response.headers['Content-Length'])
def download_range(url, start, end, output_file):
req = requests.get(
url,
headers = { 'Range': 'bytes=' + str(start) + '-' + str(end) },
stream = True,
)
req.raise_for_status()
with open(output_file, 'r+b') as fd:
fd.seek(start)
for block in req.iter_content(4096):
fd.write(block)
def download_url(url, output_file):
file_size = get_size_by_url(url)
range_size = 10485760 # 10 МиБ
ranges_count = (file_size + range_size - 1) // range_size
with open(output_file, 'wb') as fd:
# Выделяем место под файл заранее:
os.posix_fallocate(fd.fileno(), 0, file_size)
processes = []
for i in range(ranges_count):
start = i * range_size
end = start + range_size - 1
if end >= file_size:
end = file_size - 1
# Подготавливаем процесс и запускаем его в фоновом режиме:
process = Process(
target = download_range, # эта функция будет работать в фоне
args = (url, start, end, output_file),
)
process.start()
processes.append(process)
for process in processes:
process.join() # ожидаем завершения каждого процесса
Подстановка процессов
Отдельной темой, которую стоит упомянуть, является подстановка процессов в Bash через конструкцию <(...)
, поскольку не все о ней знают, но она очень облегчает жизнь. Иногда требуется передать командам потоки информации от других процессов, но при этом сами команды могут лишь принимать на вход пути к файлам. Можно было бы перенаправить вывод процессов во временные файлы, но такой код будет громоздким. Поэтому в Bash есть поддержка подстановки процессов. По факту создаётся виртуальный файл в пространстве /dev/fd/
, через который и передаётся информация посредством передачи имени этого файла в необходимую команду в качестве обычного аргумента.
# Ищем общие процессы на двух хостах:
comm
<(ssh user1@host1 'ps -x --format cmd' | sort)
<(ssh user2@host2 'ps -x --format cmd' | sort)
from subprocess import check_output
def get_common_lines(lines1, lines2):
i, j = 0, 0
common = []
while i < len(lines1) and j < len(lines2):
while lines2[j] < lines1[i]:
j += 1
if j >= len(lines2):
return common
while lines2[j] > lines1[i]:
i += 1
if i >= len(lines1):
return common
common.append(lines1[i])
i += 1
j += 1
return common
lines1 = check_output(
['ssh', 'user1@host1', 'ps -x --format cmd'],
text = True,
).splitlines()
lines1.sort()
lines2 = check_output(
['ssh', 'user2@host2', 'ps -x --format cmd'],
text = True,
).splitlines()
lines2.sort()
print(*get_common_lines(lines1, lines2), sep='n')
Переменные окружения
Работа с переменными окружения
Переменные окружения позволяют передавать информацию от родительских процессов к дочерним. В Bash встроена поддержка переменных окружения на уровне языка, но отсутствует какой‑либо ассоциативный массив всех переменных окружения. Получить информацию о них можно лишь через внешнюю команду env
.
# Присвоение значения переменной окружения:
export SOME_ENV_VAR='Some value'
echo "${SOME_ENV_VAR}" # получение значения
env # вывести список переменных окружения с помощью внешней команды
import os
# Присвоение значения переменной окружения:
os.environ['SOME_ENV_VAR'] = 'Some value'
print(os.environ['SOME_ENV_VAR']) # получение значения
print(os.environ) # вывести массив переменных окружения
Задание значения для отдельных процессов
Переменные окружения передаются от родительского процесса к дочерним. Иногда может потребоваться изменить лишь одну переменную окружения. Поскольку Python позиционируется как язык прикладного программирования, то на нём это будет несколько сложнее, в Bash же поддержка такого задания переменных встроена:
# Устанавливаем русскую локализацию для запускаемых приложений
export LANG='ru_RU.UTF-8'
LANG='C' ls --help # а эту команду запустим с английский локазиацией
echo "LANG=${LANG}" # убедимся, что переменные окружения не затронуты
import os
import subprocess
# Присвоение значения переменной окружения:
os.environ['LANG'] = 'ru_RU.UTF-8'
new_env = os.environ.copy()
new_env['LANG'] = 'C'# Присвоение значения переменной окружения:
export SOME_ENV_VAR='Some value'
echo "${SOME_ENV_VAR}" # получение значения
subprocess.run(
['ls', '--help'],
env = new_env,
)
print('LANG=' + os.environ['LANG']) # убедимся, что переменные окружения не затронуты
Выполнение произвольного кода
Выполнять произвольный код в обыденных ситуациях не требуется, но в обоих языках присутствует такая возможность. В Bash это может пригодиться, например, чтобы возвращать изменённые процессом переменные или чтобы вообще возвращать именованные результаты исполнения. В Python же есть два оператора: eval()
и exec()
. Аналогом eval
языка Bash в данном случае является оператор exec()
, поскольку позволяет выполнять список команд, а не только вычислять выражения. Использование eval()
и exec()
является очень плохой практикой в Python, и эти операторы всегда можно заменить чем‑то более подходящим, если только не требуется написать собственный командный интерпретатор на основе Python.
get_user_info()
{
echo "user=`whoami`"
echo "curr_dir=`pwd`"
}
eval $(get_user_info) # исполняем вывод команды
echo "${user}"
echo "${curr_dir}"
import getpass
import os
def user_info_code():
return f"""
user = '{getpass.getuser()}' # очень плохая практика
curr_dir = '{os.getcwd()}' # не делайте так, пожалуйста
"""
exec(user_info_code())
print(user)
print(curr_dir)
# Но возвращать именованные значения вообще
# лучше через классы, namedtuple или словари
from collections import namedtuple
import getpass
import os
UserInfo=namedtuple('UserInfo', ['user', 'curr_dir'])
def get_user_info():
return UserInfo(getpass.getuser(), os.getcwd())
info = get_user_info()
print(info.user)
print(info.curr_dir)
Работа с файловой системой и процессами
Получение и смена текущего каталога
Менять текущий каталог в командной строке обычно требуется, когда что‑то делается вручную. А вот получать текущий каталог может понадобиться и в скриптах, например, если скрипт ли запускаемая программа что‑то делает над файлами в текущем каталоге. По той же причине может понадобиться и менять текущий каталог, если требуется запустить другую программу, которая что‑то выполняет в нём.
current_dir=`pwd` # получить текущий каталог
echo "${current_dir}"
cd /some/path # перейти в каталог
import os
current_dir = os.getcwd() # получить текущий каталог
print(current_dir)
os.chdir('/some/path') # перейти в каталог
Работа с сигналами
В Bash команда kill
является встроенной, собственно, поэтому man kill
будет выдавать справку совсем по другой команде с отличными аргументами. К слову, sudo kill
будет уже вызывать именно утилиту kill
. Но код на Python все же слегка понятнее.
usr1_handler()
{
echo "Получен сигнал USR1"
}
# Назначаем обработчик сигнала SIGUSR1:
trap 'usr1_handler' USR1
# Послать сигнал текущему интерпретатору:
kill -USR1 $$ # $$ — PID родительского интерпретатора
Возможность компиляции
Bash по определению не поддерживает компиляцию своих скриптов, возможно, поэтому всё в нём стремится к минимализму в названиях. Python же хоть и является интерпретируемым, но может быть скомпилирован в платформонезависимый байт‑код, исполняемый виртуальной машиной Python (PVM). Исполнение такого кода позволяет повысить производительность работы скриптов. Обычно файлы байт‑кода имеют расширение .pyc.
Выбор языка в зависимости от задачи
В качестве итога статьи можно cформировать основные постулаты, какой язык в каких случаях лучше использовать.
Bash выгоднее использовать в случаях:
-
решения простых задач, которые можно быстрее решить с хорошими знаниями языка;
-
простых сценариев командной строки, где производится работа с процессами, файлами, каталогами или вообще с жесткими дисками и файловой системой;
-
если создаются обёртки над другими командами (старт командного интерпретатор может быть быстрее, нежели интерпретатора Python);
-
если по какой‑то причине Python отсутствует в системе.
Python больше подойдёт для случаев:
-
решения задач, связанных с обработкой текста, математическими вычислениями или реализацией нетривиальных алгоритмов;
-
если код на Bash будет трудночитаемым и малопонятным;
-
если требуется покрывать код модульными тестами (модуль
unittest
); -
если требуется разбор большого набора параметров командной строки с иерархией опций между командами;
-
если требуется отображение графических диалоговых окон;
-
если критична производительность именно в работе скрипта (старт в Python может быть медленнее, но исполнять код он может быстрее);
-
для создания постоянно работающих служб (сервисы systemd).
Рекомендуемая литература
-
Cooper M., Advanced Bash‑Scripting Guide / M. Cooper. — URL: https://tldp.org/LDP/abs/html/index.html. — Дата обращения: 02.01.2025 г.
-
Python 3 documentation. — URL: https://docs.python.org/3/. — Дата обращения: 02.01.2025 г.
Лицензия
Текст статьи публикуется на условиях лицензии Creative Commons Attribution 4.0 International, достаточным условием атрибуции в плане авторства является указание ссылки на оригинальную статью с её названием.
Исходные коды, опубликованные в статье, доступны на условиях лицензии СС0 1.0 Universal, — примеры можно свободно использовать в своём коде без указания авторства.
Автор: andrey0700