Практическое написание сценариев командной оболочки Bash/Код-сниппеты: различия между версиями

Материал из Викиучебника — открытых книг для открытого мира
Содержимое удалено Содержимое добавлено
Строка 913: Строка 913:


=== Пул процессов ===
=== Пул процессов ===
;Цель
* Запустить несколько процессов одновременно и дождаться их завершения.

;Предисловие


== Рисование на терминале ==
== Рисование на терминале ==

Версия от 08:21, 12 октября 2021

← Команда read Глава
Код-сниппеты


Эту книгу мы хотели бы закончить код-сниппетами или небольшими фрагментами Bash-кода, которые кочуют из одного сценария в другой. Вы можете копировать их без изменений или немного править под ваши собственные нужды. Основной целью код-сниппетов является экономия времени: вы не должны тратить время на то, что уже давно придумано.

Соглашения по коду

В основном мы придерживаемся всех тех правил, о которых рассказали в этой книге. Как мы говорили, не все синтаксические правила Bash являются переносимыми, но в этих сниппетах мы не заботимся о переносимости.

Если переносимость кода между оболочками для вас важна, обращайте внимание на следующее:

  • Обращайте внимание на подстановочные операции. Если в целевой оболочке какие-то из них не поддерживаются, то их следует заменять эквивалентными. Эквивалентности можно добиться разными утилитами. Обычно нужно попробовать отыскать подходящую среди следующих: awk и sed (потоковые редакторы); let, expr, bc (регулярные выражения и вычисления); grep, egrep и pgrep (регулярные выражения); tr (преобразование символов); cut (выделение подстрок); find (поиск файлов) и др.
  • Обращайте внимание на опции встроенных команд. Не все опции, которые есть у встроенных команд в Bash, описаны в POSIX. От таких опций нужно отказываться и искать альтернативные решения в переносимых сценариях.
  • Кроме того, синтаксис самих внутренних команд может немного отличаться из-за встроенных возможностей Bash. Например:
    trap -- - SIGINT # В Bash допустимо
    
    но по POSIX это не работает
    trap - SIGINT # POSIX-совместимый вариант
    
  • Обращайте внимание на встроенные переменные. Множество переменных поддерживается только в Bash: BASHPID, BASH_SOURCE, BASH_VERSION и др.

Далее мы придерживаемся следующих правил:

  • Если переменная видима в глобальной области, то ее имя записывается прописными буквами, если в локальной области — строчными.
  • Переменные и функции, имена которых начинаются на символ нижнего подчеркивания, являются технологическими. Другими словами, в нормальных условиях клиентский код не должен к ним обращаться.
  • Имена функций мы пишем строчными буквами, как это принято в программах на языке Си.

Весь код поделен на различные категории.

Функции общего пользования

Стандартные сообщения

Цель
  • Вывод на терминал сообщений в общем стиле.
Код
# Управляющие последовательности для подкраски слов
declare -r NORMAL_COLOR="\e[0;39m"    # Сброс цвета
declare -r GREEN_COLOR="\e[0;32m"     # Зеленый цвет
declare -r WARNING_COLOR="\e[0;33m"   # Желтый цвет
declare -r FAILURE_COLOR="\e[0;31m"   # Красный цвет
declare -r INFO_COLOR="\e[0;36m"      # Голубоватый цвет (циан)
declare -r BLUE_COLOR="\e[1;34m"      # Синий цвет

# Стандартный формат
readonly GET_TIME='$(date +%T)' # Для печати времени в начале

# Массив хранит форматы для различных категорий сообщений
declare -r -A __COMM_SAY_MESG=(
    [INFO_COLOR]="echo -e \"${GREEN_COLOR}$GET_TIME ${BLUE_COLOR}[${INFO_COLOR}INFO${BLUE_COLOR}]${NORMAL_COLOR}:\""
    [ERROR]="echo -e \"${GREEN_COLOR}$GET_TIME ${BLUE_COLOR}[${FAILURE_COLOR}ERROR${BLUE_COLOR}]${NORMAL_COLOR}:\""
    [WARNING_COLOR]="echo -e \"${GREEN_COLOR}$GET_TIME ${BLUE_COLOR}[${WARNING_COLOR}WARNING${BLUE_COLOR}]${NORMAL_COLOR}:\""
    [FORMAT]='%s %s %s %s %s %s %s %s %s %s\n'
    [FORMAT_E]='%s   %s %b%s%b\n'
    [FORMAT_W]='%s %s %b%s%b\n'
    [FORMAT_I]='%s    %s %b%s%b\n'
) 
#
# Печатает на терминал сообщение в стандартном формате.
#
# Пример:
#    say [-i|-w|-e] "Message"
#
say() {
    [[ $# -ne 0 ]] || return 1
    local flag message
    [[ $# == 1 ]] && message=$1
    [[ $# == 2 ]] && flag=$1 message=$2 
    local -a mesg=()
    case "$flag" in
        -i|--info)
            mesg=($(eval ${__COMM_SAY_MESG[INFO_COLOR]}) "${GREEN_COLOR}$message${NORMAL_COLOR}")
            printf "${__COMM_SAY_MESG[FORMAT_I]}" "${mesg[@]}"
            ;;
        -w|--warning)
            mesg=($(eval ${__COMM_SAY_MESG[WARNING_COLOR]}) "${WARNING_COLOR}$message${NORMAL_COLOR}")
            printf "${__COMM_SAY_MESG[FORMAT_W]}" "${mesg[@]}"
            ;;
        -e|--error)
            mesg=($(eval ${__COMM_SAY_MESG[ERROR]}) "${FAILURE_COLOR}$message${NORMAL_COLOR}")
            printf "${__COMM_SAY_MESG[FORMAT_E]}" "${mesg[@]}"
            ;;
        *)
            mesg=($message)
            printf "${__COMM_SAY_MESG[FORMAT]}" ${mesg[@]}
            ;;
    esac
}
Описание

Единственная функция say() печатает на терминал пользовательские сообщения. Клиентский код может разделять сообщения на три категории: ошибки, предупреждения и информационные сообщения. Категория сообщения определяется опцией функции, которая передается в первом аргументе. Во втором аргументе передается сообщение.

По умолчанию для всех категорий поддерживается подкраска вывода: ошибки выводятся красными, предупреждения желтыми и информационные сообщения зелеными. Функция поддерживает пользовательский формат, если передавать только один аргумент. Пользовательский формат по умолчанию выводит строку в 10 колонок и может быть настроен разработчиком как ему нужно.

Функцию можно дописывать, добавляя новые категории и настраивая форматы по аналогии.

Примеры
say -i "Informational message"
# 15:06:03    [INFO]: Informational message

say -w "Warning message"
# 15:06:03 [WARNING]: Warning message

say -e "Error message"
# 15:06:03   [ERROR]: Error message

say "$(date --rfc-3339=seconds) [CUSTOM]: Custom message"
# 2021-10-06 15:06:03+03:00 [CUSTOM]: Custom message

Завершение сценария

Цель
  • Одинаковая обработка для всех точек выхода сценария.
Код
#
# die [<code> [<error message>]]
#
die() {
    [[ $# -eq 0 ]] && exit 1
    local exit_code=$1
    local message=$2
    [[ ! -z $message ]] &&
        say -e "$message"
    exit $exit_code
}
Описание

Функция die() завершает работу сценария. Возможно три варианта использования функции:

  • Вызов функции без аргументов. Сценарий завершается с кодом 1.
  • Вызов функции с одним аргументом. В первом аргументе передается код, который возвращает сценарий.
  • Вызов с двумя аргументами. В первом аргументе передается код возврата, а во втором сообщение, которое нужно вывести перед выходом. Ожидается, что выход в этом случае происходит при наступлении ошибочной ситуации.
Примеры
check_some_condition || die # Или успех проверки, или выход с кодом 1
check_another_condition || die 25 # Или успех проверки, или выход с кодом 25

check_something_else || die 3 "Check failure" # Или успех проверки, или выход с кодом 3 и выводом сообщения

die 0 # Конец сценария

Перехват ошибок

Цель
  • Печать на терминал однотипных сообщений об ошибках.
Код
handle_error() {
    [[ $# -ne 0 ]] || return 1
    local expectations exps wrong_value
    local redirection=${__COMM_HANDLE_ERROR_REDIRECTION:-'say -e'}
    case $1 in
    --required-arg)
        shift
        wrong_value=${1}
        shift
        expectations=${1}
        ${redirection} "Required argument for \"$wrong_value\": $expectations"
        ;;
    --wrong-arg-number)
        shift
        ${redirection} "Passed wrong number of arguments (expected $1)"
        ;;
    --not-a-number)
        shift
        ${redirection} "\"$1\" is not a number"
        ;;
    --invalid-value)
        shift
        wrong_value=${1}
        shift
        expectations=${1}
        [[ ! -z $expectations ]] && exps=" (Expectations: \"$expectations\")"
        ${redirection} "Invalid value: \"$wrong_value\".$exps"
        ;;
    --invalid-option)
        shift
        wrong_value=${1}
        shift
        expectations=${1}
        [[ ! -z $expectations ]] && exps=" (Expectations: \"$expectations\")"
        ${redirection} "Invalid option: \"$wrong_value\".$exps"
        ;;
    esac
    return 0
}
Описание

Функция handle_error() служит для печати однотипных сообщений об ошибках. Может использоваться при проверке входящих опций сценария. В этой реализации предусмотрены следующие ключи:

  • --required-arg <what> <expectations>. Для указанного what не предоставлен аргумент. В параметре expectations указываются ожидаемые значения.
  • --wrong-arg-number <number>. Передано неверное количество аргументов. Ожидаемое число указывается в number.
  • --not-a-number <value>. Переданное значение value не является числом.
  • --invalid-value <value> [<expectations>]. Неверное значение. В expectations указываются ожидания, однако если ошибка понятна из контекста, то они могут быть опущены.
  • --invalid-option <value> [<expectations>]. Неизвестная опция. В expectations указываются ожидания, однако если ошибка понятна из контекста, то они могут быть опущены.
Примеры
handle_error --required-arg "-a" "<number>"
# 19:52:46   [ERROR]: Required argument for "-a": <number>

handle_error --wrong-arg-number "3"
# 19:52:46   [ERROR]: Passed wrong number of arguments (expected 3)

handle_error --not-a-number 'a word'
# 19:52:46   [ERROR]: "a word" is not a number

handle_error --invalid-value "14"
# 19:52:46   [ERROR]: Invalid value: "14".

handle_error --invalid-value "14" "100-200"
# 19:52:46   [ERROR]: Invalid value: "14". (Expectations: "100-200")

handle_error --invalid-option "-r"
# 19:52:46   [ERROR]: Invalid option: "-r".

handle_error --invalid-option "-a 1a" "-a <number>"
# 19:52:46   [ERROR]: Invalid option: "-a 1a". (Expectations: "-a <number>")

Отладочное логирование

Цель
  • Внедрение единой системы отладочной печати в сценарий.
Код
# Файл: logger.bash
# ==================================
# Библиотека отладочного логирования
# ==================================
# Автор: Grigorii Okhmak
#
# Как использовать
# ----------------
#   Для установки уровня логирования используется функция dbg_set_log_level <уровень-логирования>.
#   Уровень логирования также может быть исправлен через переменную __DBG_LOG_LEVEL. Значение по умолчанию 1.
#   Уровень логирования изменяется от 0 до 3:
#      - 0 (DBG_ERROR_LEVEL)     Логировать только ошибки.
#      - 1 (DBG_WARNING_LEVEL)   Логировать ошибки и предупреждения.
#      - 2 (DBG_INFO_LEVEL)      Логировать ошибки, предупреждения и информационные сообшения.
#      - 3 (DBG_DEBUG_LEVEL)     Логировать ошибки, предупреждения, информационные сообшения, отладочная печать и стектрейсы.
#   Значения больше 3 трактуются как 3.
#   
#   Для логирования используйте функцию-обертку dbg_log.
#
#   dbg_log [-i|-w|-e] "Message"
#
#     Функция пишет в файл, указанный в переменной __DBG_LOG_FILE (по умолчанию, 'trace.log', создаваемый в
#     рабочем каталоге сценария). Функция пишет только в том случае, если достигнут уровень логирования:
#        dbg_log -e "Error message"         печатает при __DBG_LOG_LEVEL >=0
#        dbg_log -w "Warning message"       печатает при __DBG_LOG_LEVEL >=1
#        dbg_log -i "Informational message" печатает при __DBG_LOG_LEVEL >=2
#        dbg_log "Debug message"            печатает при __DBG_LOG_LEVEL >=3
#
#   Функция пишет в файл конкурентно, через блокировку flock. Управлять блокировкой можно двумя константами:
#      - __DBG_GUARD_COUNTER    Число попыток захватить файл. Должно быть больше 0. Ноль означает неограниченное число.
#      - __DBG_SLEEP_TIME       Пауза между попытками. По умолчанию 0.1 секунды.
#
# Библиотека позволяет логировать стектрейсы по прерыванию ERR. Для этого нужно повесить на это прерывание функцию dbg_log
# следующим образом:
# 
#       trap 'dbg_log -s1' ERR    или    trap 'dbg_log --auto-err-trace' ERR
#
# Для повышения подробности стектрейсов, используйте set -E. При таком логировании
# заметны просадки производительности, поэтому пользуйтесь этим аккуратно. В стектрейсы попадает любой код, который
# не вернул нулевой код возврата.
#
# Формат сообщений
# ----------------
# 
# 31663 2021-10-07 22:33:40.504926800+03:00 [ERROR]: ./test.sh:func_name:204: Error message
#   |                |                         |          |        |      |       \____________________  Пользовательское сообщение
#   |                |                         |          |        |      \____________________________  Номер строки в исходном файле, из которой вызывается логер
#   |                |                         |          |        \___________________________________  Имя функции, в которой делается вызов логера
#   |                |                         |          \____________________________________________  Имя исходного файла
#   |                |                         \_______________________________________________________  Тип сообщения: ERROR|WARN|INFO|DEBUG|AUTO_TRACE
#   |                \_________________________________________________________________________________  Время логирования в формате RFC-3339
#   \__________________________________________________________________________________________________  PID корневой командной оболочки
#
# Пример трейса
#
# 5165 2021-10-08 10:52:09.621854100+03:00 [AUTO_TRACE]: ./script.sh:a:1: -s1
#   Trace started from function "a", file=a.sh, line=1
#     called from function "main", file=./script.sh, line=38
#
# Лицензия
# --------
# MIT License
#
# Copyright (c) 2021 Grigorii Okhmak
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#

[[ ! -z $_LIB_DEBUG_PREFIX && ! -z $_LIB_DEBUG_VERSION ]] &&
    {
        echo "Error: ${BASH_SOURCE##*/}:${BASH_LINENO[1]}: double library importing."
        exit 129
    }

type flock &>/dev/null ||
    {
        echo "Error: ${BASH_SOURCE##*/}:${BASH_LINENO[1]}: Not found 'flock' command."
        exit 129
    }

readonly _LIB_DEBUG_PREFIX='__dbg'
readonly _LIB_DEBUG_VERSION='0.9'

readonly DBG_ERROR_LEVEL=0
readonly DBG_WARNING_LEVEL=1
readonly DBG_INFO_LEVEL=2
readonly DBG_DEBUG_LEVEL=3

declare -a __DBG_STACK_TRACE=()
declare -i __DBG_LOG_LEVEL=${__DBG_LOG_LEVEL:-$DBG_WARNING_LEVEL}
declare __DBG_LOG_FILE=${__DBG_LOG_FILE:-trace.log}
declare -r __DBG_SLEEP_TIME=0.1
declare -i __DBG_GUARD_COUNTER=0

dbg_set_log_level() {
    if [[ ! -z "$1" ]]; then
        __DBG_LOG_LEVEL=$1
    fi
}

dbg_log() {
    [[ ($1 == '-s1' || $1 == '--auto-err-trace') && $__DBG_LOG_LEVEL -lt 3 ]] && return 0
    (
        local guard=$__DBG_GUARD_COUNTER
        exec 3>>"$__DBG_LOG_FILE"
        until flock -nx 3; do
            if (( __DBG_GUARD_COUNTER > 0 )); then
                (( guard-- != 0 )) || { echo "Warning: Bad lock: Cannot write to '$__DBG_LOG_FILE'"; exit 0;}
            fi
            sleep $__DBG_SLEEP_TIME
        done
        __dbg_print_entry "$@" >&3
    )
}

__dbg_print_entry() {
    local file lineno funcname message
    local fl=${BASH_SOURCE[((${#BASH_SOURCE[@]} - 1))]}
    file="${fl:-unknown}"
    file=${file##*content/}
    lineno="${BASH_LINENO[1]}"
    funcname="${FUNCNAME[2]}"
    if [[ ! -z "$2" ]]; then
        message="$2"
    elif [[ ! -z "$1" ]]; then
        message="$1"
    else
        message="<empty>"
    fi
    case "$1" in
    -i | --info)
        [[ $__DBG_LOG_LEVEL -ge 2 ]] || return
        __dbg_print_prefix "INFO"
        printf '%s:%s:%d: %s\n' "$file" "$funcname" "$lineno" "$message"
        ;;
    -w | --warning)
        [[ $__DBG_LOG_LEVEL -ge 1 ]] || return
        __dbg_print_prefix "WARN"
        printf '%s:%s:%d: %s\n' "$file" "$funcname" "$lineno" "$message"
        ;;
    -e | --error)
        [[ $__DBG_LOG_LEVEL -ge 0 ]] || return
        __dbg_print_prefix "ERROR"
        printf '%s:%s:%d: %s\n' "$file" "$funcname" "$lineno" "$message"
        ;;
    -s1 | --auto-err-trace)
        [[ $__DBG_LOG_LEVEL -ge 3 ]] || return
        __dbg_print_prefix "AUTO_TRACE"
        printf '%s:%s:%d: %s\n' "$file" "$funcname" "$lineno" "$message"
        __dbg_capture_stack_trace
        __dbg_print_stack_trace "${__DBG_STACK_TRACE[@]}"
        ;;
    *)
        [[ $__DBG_LOG_LEVEL -ge 3 ]] || return
        __dbg_print_prefix "DEBUG"
        printf '%s:%s:%d: %s\n' "$file" "$funcname" "$lineno" "$message"
        ;;
    esac
}

__dbg_print_prefix() {
    local prefix="$$ $(date --rfc-3339='ns')"
    if [[ ! -z "$1" ]]; then
        prefix="$prefix [$1]: "
    else
        prefix="$prefix: "
    fi
    printf "$prefix"
}

__dbg_capture_stack_trace() {
    local i
    local file funcname
    __DBG_STACK_TRACE=()
    for ((i = 3; i != ${#FUNCNAME[@]}; ++i)); do
        file="${BASH_SOURCE[$i]:-unknown}"
        funcname="${FUNCNAME[$i]}"
        __DBG_STACK_TRACE+=("${BASH_LINENO[$((i - 1))]} $funcname $file")
    done
}

__dbg_print_stack_trace() {
    local frame
    local file lineno
    local is_first=1
    for frame in "$@"; do
        printf "  "
        __dbg_frame_filename "$frame" 'file'
        __dbg_frame_lineno "$frame" 'lineno'
        local fn
        __dbg_frame_function "$frame" 'fn'
        if [[ $is_first -eq 1 ]]; then
            printf "Trace started from "
        else
            printf "  called from "
        fi
        printf 'function "%s", file=%s, line=%d\n' "$fn" "$file" "$lineno"
        is_first=0
    done
}

__dbg_frame_filename() {
    local __filename="${1#* }"
    __filename="${__filename#* }"
    printf -v "$2" '%s' "$__filename"
}

__dbg_frame_lineno() {
    printf -v "$2" '%s' "${1%% *}"
}

__dbg_frame_function() {
    local __function="${1#* }"
    printf -v "$2" '%s' "${__function%% *}"
}
Описание

Здесь предоставлена библиотека отладочного логирования. Прочитайте документацию в комментарии библиотеки. В общих словах, вы должны размещать вызов dbg_log в различных местах вашего приложения, которые вы хотите логировать. Вы должны предоставить функции тип логируемого сообщения и собственно само сообщение. Функции логирования оформляют вывод в определенном формате, из которого вы можете подчерпнуть из какого файла и из какой строки происходит вызов; время и идентификатор процесса.

Библиотека имеет следующие ограничения по использованию:

  • Работает только в Bash.
  • Требует команду flock, так как реализует с помощью нее конкурентную запись в лог-файл.
Примеры
#!/bin/bash
# Файл: script.sh

source "logger.bash" # Подключаем библиотеку логирования

set -E                 # Устанавливаем для того, чтобы все подоболочки наследовали перехват прерывания ERR.
trap 'dbg_log -s1' ERR # Вешаем обработчик на прерывание ERR

dbg_set_log_level 3 # Устанавливаем самый высокий уровень логирования

# Клиентский код

func_a() {
    dbg_log -i "Enter to the function." # Логируем информационное сообщение
    func_b
}
func_b() {
    dbg_log -w "Something goes wrong." # Логируем предупреждающее сообщение
    func_c
}
func_c() {
    dbg_log -e "Some error message." # Логируем ошибку
    return 1
}

func_d() {
    dbg_log "Some error message." # Логируем отладочное сообщение
}

func_a
func_d

(func_a) &
(func_a) &

Если мы исполним этот сценарий и заглянем в отладочный лог, то увидим следующее.

5416 2021-10-08 11:46:38.978303300+03:00 [INFO]: ./script.sh:func_a:12: Enter to the function.
5416 2021-10-08 11:46:38.997149000+03:00 [WARN]: ./script.sh:func_b:16: Something goes wrong.
5416 2021-10-08 11:46:39.015211600+03:00 [ERROR]: ./script.sh:func_c:20: Some error message.
5416 2021-10-08 11:46:39.033521200+03:00 [AUTO_TRACE]: ./script.sh:func_b:17: -s1
  Trace started from function "func_b", file=./script.sh, line=17
    called from function "func_a", file=./script.sh, line=13
    called from function "main", file=./script.sh, line=28
5416 2021-10-08 11:46:39.052091900+03:00 [AUTO_TRACE]: ./script.sh:func_a:13: -s1
  Trace started from function "func_a", file=./script.sh, line=13
    called from function "main", file=./script.sh, line=28
5416 2021-10-08 11:46:39.070994400+03:00 [AUTO_TRACE]: ./script.sh:main:28: -s1
  Trace started from function "main", file=./script.sh, line=28
5416 2021-10-08 11:46:39.089387600+03:00 [DEBUG]: ./script.sh:func_d:25: Some error message.
5416 2021-10-08 11:46:39.109032700+03:00 [INFO]: ./script.sh:func_a:12: Enter to the function.
5416 2021-10-08 11:46:39.127673400+03:00 [WARN]: ./script.sh:func_b:16: Something goes wrong.
5416 2021-10-08 11:46:39.146040400+03:00 [ERROR]: ./script.sh:func_c:20: Some error message.
5416 2021-10-08 11:46:39.163866100+03:00 [AUTO_TRACE]: ./script.sh:func_b:17: -s1
  Trace started from function "func_b", file=./script.sh, line=17
    called from function "func_a", file=./script.sh, line=13
    called from function "main", file=./script.sh, line=31
5416 2021-10-08 11:46:39.182567800+03:00 [AUTO_TRACE]: ./script.sh:func_a:13: -s1
  Trace started from function "func_a", file=./script.sh, line=13
    called from function "main", file=./script.sh, line=31
5416 2021-10-08 11:46:39.201203900+03:00 [AUTO_TRACE]: ./script.sh:main:31: -s1
  Trace started from function "main", file=./script.sh, line=31
5416 2021-10-08 11:46:39.224989300+03:00 [INFO]: ./script.sh:func_a:12: Enter to the function.
5416 2021-10-08 11:46:39.242894400+03:00 [WARN]: ./script.sh:func_b:16: Something goes wrong.
5416 2021-10-08 11:46:39.260812900+03:00 [ERROR]: ./script.sh:func_c:20: Some error message.
5416 2021-10-08 11:46:39.278845900+03:00 [AUTO_TRACE]: ./script.sh:func_b:17: -s1
  Trace started from function "func_b", file=./script.sh, line=17
    called from function "func_a", file=./script.sh, line=13
    called from function "main", file=./script.sh, line=32
5416 2021-10-08 11:46:39.297207300+03:00 [AUTO_TRACE]: ./script.sh:func_a:13: -s1
  Trace started from function "func_a", file=./script.sh, line=13
    called from function "main", file=./script.sh, line=32
5416 2021-10-08 11:46:39.315230900+03:00 [AUTO_TRACE]: ./script.sh:main:32: -s1
  Trace started from function "main", file=./script.sh, line=32

Взаимодействие с пользователем

Запрос данных у пользователя

Цель
  • Одинаковым образом запрашивать пользовательский ввод.
  • Одинаковым образом запрашивать подтверждение пользовательского ввода.
Код
__is_yes_or_no() {
    [[ $# -ne 0 ]] || return 2
    [[ ! -z $1 ]] || return 2
    local line=$1
    while
        [[ $line =~ ^[Yy]$ || $line =~ ^[Yy]es$ ]] && { [[ ! -z $2 ]] && printf -v "$2" '0'; return 0;}
        [[ $line =~ ^[Nn]$ || $line =~ ^[Nn]o$ ]]  &&
            { 
                [[ ! -z $2 ]] && printf -v "$2" '1'
                [[ ! -z $4 ]] && return 1 || return 0
            }
        if [[ ! -z $3 ]]; then
            read -p "Please enter [Yy]es or [Nn]o: " line
        else
            return 1
        fi
    do true; done
}

ask1() {
    [[ $# -ge 2 ]] || return 1
    local prompt=$1
    local variable_name=$2
    local skip=$3
    local ret
    unset REPLY
    until __is_yes_or_no "$REPLY" ret --strictly; do
        read -e -p "$prompt"
        printf -v "$variable_name" "$REPLY"
        [[ ! -z $skip && $skip -eq 1 ]] && REPLY="yes"
        [[ -z $skip || $skip -eq 0 ]] && read -p "You entered '${!variable_name}'. Is it correct? [yes/no]: "
    done
    unset REPLY
    [[ $ret -eq 0 ]] || unset $variable_name
    return $ret
}

ask2() {
    [[ $# -ge 2 ]] || return 1
    local prompt=$1
    local variable_name=$2
    local skip=$3
    local ret
    unset REPLY
    until __is_yes_or_no "$REPLY" ret --strictly --repeatable; do
        if [[ $ret -eq 1 ]]; then
            read -p "Do you want to re-enter? [yes/no]: "
            __is_yes_or_no "$REPLY" ret --strictly
            [[ $ret -eq 1 ]] && break
        fi
        read -e -p "$prompt"
        printf -v "$variable_name" "$REPLY"
        [[ ! -z $skip && $skip -eq 1 ]] && REPLY="yes"
        [[ -z $skip || $skip -eq 0 ]] && read -p "You entered '${!variable_name}'. Is it correct? [yes/no]: "
    done
    unset REPLY
    [[ $ret -eq 0 ]] || unset $variable_name
    return $ret
}
Описание

Здесь реализовано две функции ask1() и ask2(), чтобы спросить у пользователя что-нибудь. В обоих функциях ввод не валидируется, поэтому здесь реализован простой запрос на подтверждение того, что пользователь ввел. Подтверждение ввода будет запрашиваться по умолчанию всегда, но эту возможность можно отключить, если передавать необязательным третьим аргументом единицу.

Обе функции ожидают два обязательных параметра:

  1. Вопрос, который нужно напечатать перед вводом.
  2. Переменная, в которую нужно записать то, что ввел пользователь.

Функции возвращают ноль, если пользователь подтверждает ввод, и единицу, если пользователь отказался подтверждать ввод. Для безопасности, если ввод не был завершен, то последняя строка, которая вводилась пользователем, не сохраняется.

Разница между ask1() и ask2() в том, что ask2() предлагает повторить ввод, если пользователь не согласился с введенным значением.

Функция просмотра подтверждения ввода строго следит за тем, что написал пользователь. Если введено слово не похожее ни на Yes, ни на No, то функция попросит повторить ввод подтверждения. Это сделано для защиты от опечаток. Проверка на Yes/No разрешает однобуквенную запись (Y/N).

Примеры
ask1 "Enter your name: " NAME
echo "My name is ${NAME:-NO_NAME}. Last code is $?"

# Enter your name: John
# You entered 'John'. Is it correct? [yes/no]: yes
# My name is John. Last code is 0

# Enter your name: John
# You entered 'John'. Is it correct? [yes/no]: no
# My name is NO_NAME. Last code is 1

# Без подтверждения
ask1 "Enter your name: " NAME 1
echo "My name is ${NAME:-NO_NAME}. Last code is $?"

# Enter your name: Bill
# My name is Bill. Last code is 0

ask2 "Enter your name: " NAME
echo "My name is ${NAME:-NO_NAME}. Last code is $?"
# Enter your name: Bill
# You entered 'Bill'. Is it correct? [yes/no]: YYY
# Please enter [Yy]es or [Nn]o: N
# Do you want to re-enter? [yes/no]: yes
# Enter your name: John
# You entered 'John'. Is it correct? [yes/no]: y
# My name is John. Last code is 0

Приостановить исполнение сценария

Цель
  • Приостановить исполнение сценария. Например иногда это требуется, чтобы пользователь изучил тот вывод, который уже есть.
Код
pause() {
  local dummy
  read -s -r -p "${1:-Press any key to continue...}" -n 1 dummy && echo
}
Описание

Вызов функции приостанавливает исполнение до нажатия пользователем любой клавиши на клавиатуре. У функции предусмотрен необязательный аргумент для изменения стандартной надписи.

Примеры
pause
# Press any key to continue...

pause "Press any key if you agree with that or ^C to interrupt ..."
# Press any key if you agree with that or ^C to interrupt ...

Меню выбора

Цель
  • Использование меню выбора в рамках сценария.
Предисловие

Обычно для конечного пользователя меню разрабатываются с помощью таких инструментов как ncurses и/или tk, но для их поддержки необходимо, чтобы были установлены соответствующие библиотеки. Написание же полноценного многоуровнего меню на чистом языке командной оболочки очень сложная задача из-за того, что все зависит от терминального устройства и его настроек.

Ниже мы продемонстрируем основные принципы написания меню, которые вполне могут понравится системным администраторам в их вспомогательных сценариях.

Главный секрет в терминальном рисовании, это умелое манипулирование терминальным устройством через его управляющие последовательности. Для передачи многих последовательностей на терминал служит команда tput.

Код
#!/bin/bash

# Для подкраски части вывода
declare -r NORMAL_COLOR="\e[0;39m"     # Сброс цвета
declare -r GREEN_COLOR="\e[0;32m"      # Зеленый цвет
declare -r BLUE_COLOR="\e[1;34m"       # Синий цвет
declare -r YELLOW_COLOR="\e[0;33m"     # Желтый цвет
declare -r _UNDERLINE_ON=$(tput smul)  # Символ включения подчеркивания
declare -r _UNDERLINE_OFF=$(tput rmul) # Символ отключения подчеркивания
declare -i _WINDOW_SIZE_HOR=$(tput cols) # Размер терминала в символах по горизонтали
declare -i _WINDOW_SIZE_VER=$(tput lines)  # Размер терминала в символах по вертикали
declare _OPTION
declare _UNDERLINE

main() {
    __start
}

pause() {
  local dummy
  read -s -r -p "${1:-Press any key to continue...}" -n 1 dummy && echo
}

# Основаная функция перерисовки
__redraw() {
    # Актуализируем переменные
    _WINDOW_SIZE_HOR=$(tput cols)
    _WINDOW_SIZE_VER=$(tput lines)
    printf -v _UNDERLINE '%*s' $((_WINDOW_SIZE_HOR - 18))
    _UNDERLINE=${_UNDERLINE// /'-'}
    _draw_menu_root
}

# Точка входа в меню.
#
# Примечание:
# Мы вкладываем все функции вложенных меню в одну функцию чисто для удобства
__start() {
    _draw_menu_root() {
        while [[ -z ${_OPTION} ]]; do
            tput civis # Скрываем курсор, чтобы не было видно его мелькания
            tput clear # Очищаем экран для перерисовки
            # Рисуем главное меню
            # Примечание: мы ожидаем, чтобы поле Option было на 5 строке и его начало
            # было в 14 колонке. Все сдвиги в меню важны для ровной отрисовки.
            printf "
    $(echo -e "${GREEN_COLOR}Window size: ${YELLOW_COLOR}${_WINDOW_SIZE_HOR}${NORMAL_COLOR} x ${YELLOW_COLOR}${_WINDOW_SIZE_VER}${NORMAL_COLOR}")

    $(echo -e "${GREEN_COLOR}Select an item from the list. Enter some letter from the list.${NORMAL_COLOR}")

    $(echo -e "${BLUE_COLOR}Option =>${NORMAL_COLOR}") ${_UNDERLINE_ON}${_UNDERLINE}${_UNDERLINE_OFF}
    
    

    $(echo -e "${GREEN_COLOR}Main Options:${NORMAL_COLOR}")
        $(echo -e "${BLUE_COLOR}A)${NORMAL_COLOR}") $(echo -e "${YELLOW_COLOR}Menu A${NORMAL_COLOR}")
        $(echo -e "${BLUE_COLOR}B)${NORMAL_COLOR}") $(echo -e "${YELLOW_COLOR}Menu B${NORMAL_COLOR}")
        $(echo -e "${BLUE_COLOR}C)${NORMAL_COLOR}") $(echo -e "${YELLOW_COLOR}Menu C${NORMAL_COLOR}")
        $(echo -e "${BLUE_COLOR}X)${NORMAL_COLOR}") $(echo -e "${YELLOW_COLOR}Exit${NORMAL_COLOR}")
"
            # Обработчик событий главного меню
            tput rc          # Удаление текущей позиции курсора
            tput smul        # Включить режим подчеркивания ввода
            tput cnorm       # Отобразить курсор
            read _OPTION     # Ждем ввод пользователя
            tput rmul        # Отключаем режим подчеркивания
            case "${_OPTION}" in
                [Aa]) _draw_menu_A ;;
                [Bb]) _draw_menu_B ;;
                [Cc]) _draw_menu_C ;;
                [Xx]) tput clear; exit 0 ;;
                   *)
                        echo -e "\n${YELLOW_COLOR}    Invalid Option: ${_OPTION}\c${NORMAL_COLOR}"
                        sleep 1 # Задержка для отображения сообщения об ошибке
                    ;;
            esac
            unset _OPTION
        done
    }
    _draw_menu_A() {
        tput clear # Любое меню перед открытием должно затирать экран от предыдущего содержимого
        local color option
        PS3="Enter Selection [1-9]: "
        # Следующее меню мы реализуем через цикл Select.
        select color in "Black" "Blue" "Green" "Cyan" "Red" "Magenta" "Yellow" "White" "Exit"; do
            case ${REPLY} in
                [1-8])  option=$((REPLY - 1)) ;;
                    9)  break ;;
                    *)  echo "Invalid Color"; continue ;;
            esac
            if [[ ${1} = "b" ]]; then
                tput setb ${option}
            else
                tput setf ${option}
            fi
        done
    }
    _draw_menu_B() {
        tput clear
        pause "This panel is empty. Press any key to return ..."
    }
    _draw_menu_C() {
        tput clear
        pause "This panel is empty. Press any key to return ..."
    }
    ######################################################################
    tput sgr0     # Сбрасываем настройки терминала к дефолтным значениям
    tput civis    # Скрыть курсор, чтобы не показывать его перемещение перед отрисовкой
    tput clear    # Чистим экран терминала
    tput cup 5 14 # Переместить курсор на позицию X x Y (X строка, Y колонка)
    tput sc       # Сохранить эту позицию
    tput cup 0 0  # Переместить курсор в верхний левый угол
    [[ -n $_OPTION ]] && unset _OPTION
    trap '__redraw' WINCH # Привязываем функцию отрисовки к сигналу масштабирования размера экрана терминала
    trap 'tput sgr0; tput clear' EXIT SIGKILL
    __redraw
}

[[ "$0" == "$BASH_SOURCE" ]] && main "$@"
Описание

В этом примере мы рисуем меню на экране терминала. В меню предусмотрено поле Option, в которое пользователь вводит команды, разрешенные в этом меню. В данном примере пользователь должен ввести букву из списка, чтобы перейти к дочернему меню. Все дочерние меню возвращают к родительскому. Чтобы закрыть приложение, нужно ввести X. При изменении размера экрана терминала, главное меню будет перерисовываться под новые размеры.

Данная реализация не лишена недостатков и может работать с глюками в некоторых терминалах.

Демонстрация

На картинке этого не видно, но тире на самом деле отображаются подчеркнутыми.

    Window size: 122 x 58

    Select an item from the list. Enter some letter from the list.

    Option => --------------------------------------------------------------------------------------------------------



    Main Options:
        A) Menu A
        B) Menu B
        C) Menu C
        X) Exit

Ввод секретной информации

Цель
  • Ввод скрытой информации с возможностью подсчета введенных символов.
Код
#
# $1 => Текст приглашения
# $2 => Переменная для записи секрета
#
ask_secret() {
    [[ $# -ge 2 ]] || return 1
    local prompt=$1
    local buffer
    local pressed_key
    local secret
    echo -n "$prompt"
    __STTY_BACKUP="$(stty -g)"
    stty -icanon # Переходим в неканоничный режим, чтобы управляющие последовательности не обрабатывались
    while
        read -s -n1 buffer
        [[ -n $buffer ]]
    do
        pressed_key=$(printf "%d" "'$buffer") # Код нажатой клавиши
        if [[ $pressed_key -eq 127 ]]; then
            [[ ${#secret} -ne 0 ]] || continue
            # Если нажата Backspace, то удалить символ
            echo -en "\b \b"
            secret=${secret%?}
        else # иначе записать символ.
            # В этой реализации нажатие кнопки Esc и стрелок
            # мы не обрабатываем
            case "$pressed_key" in
            27 | 91 | 65 | 66 | 67 | 68) continue ;;
            esac
            secret=$secret${buffer}
            echo -en "*"
        fi
    done
    stty "$__STTY_BACKUP"
    echo
    eval $2=\"$secret\"
    unset secret buffer 
}
Описание

Классический способ ввода секретной информации через терминал в *nix обычно сводится к отключению режима echo терминала. В этом случае вводимые символы просто не выводятся на экран. Этого можно добиться, если использовать команду read с опцией -s. Неудобством такого подхода является то, что вы также не знаете сколько символов вы уже ввели. Порой, задумавшись на секунду на середине ввода пароля, пользователь путается в своих мыслях, забывая что он уже ввел, после чего он вынужден стереть пароль, лихорадочно нажимая по ← Backspace, и начать ввод заново.

Данная реализация запроса секрета позволяет скрывать вводимые символы звездочками. Также эта реализация обрабатывает нажатие ← Backspace, чтобы затереть последний символ.

Примеры
# ...

__STTY_BACKUP="$(stty -g)"              # Делаем бэкап настроек терминала, потому что функция отключает каноничный режим.
trap "stty $__STTY_BACKUP" EXIT SIGKILL # Чтобы быть уверенными, что оригинальные настройки гарантированно вернутся терминалу.

ask_secret "Enter the password: " PASSWORD
echo "Your secret: '$PASSWORD'"

Пример работы сценария.

Enter the password: *********
Your secret: 'qwerty123'

Поддержка нескольких процессов

Пул процессов

Цель
  • Запустить несколько процессов одновременно и дождаться их завершения.
Предисловие

Рисование на терминале

Рисование таблиц

Цель
  • Представление табличных данных в едином стиле.

Утилита column

Одной из утилит пакета util-linux является утилита column. Данная утилита позволяет представлять данные колонками. Это очень удобный инструмент для быстрого формирования простых таблиц. К сожалению, данная утилита имеет отличия в реализации между Debian-подобными дистрибутивами и Cent OS подобными. В основном отличие кроется в интерпретации строки разделителей полей.

Для примера пусть мы имеем следующий файл со списком контактов. Наша задача представить его таблицей.

# File: contacts.txt
Alice Brown :1989/04/03 :Accountant :555-1268
Samanta Smith :1995/12/01 :Copywriter :555-1233
John Berkley :1969/06/12 :Boss :555-1201

Matthew Tucker :1988/11/01 :Technician :  555-1230

Следующей командой

(printf "NAME:DATE OF BIRTH:POSITION:PHONE\n"; sed '1d' contacts.txt) | column -t -s ':'

мы получим

NAME             DATE OF BIRTH  POSITION     PHONE
Alice Brown      1989/04/03     Accountant   555-1268
Samanta Smith    1995/12/01     Copywriter   555-1233
John Berkley     1969/06/12     Boss         555-1201
Matthew Tucker   1988/11/01     Technician     555-1230

Здесь шапку для таблицы мы формируем сами. В качестве разделителя мы используем символ двоеточия :, чтобы можно было использовать пробелы в строках с данными. Обратите внимание что команда будет строить таблицу только с опцией -t. Разделитель мы указываем через опцию -s.

Обратите внимание, что пустые строки по умолчанию игнорируются, что в общем то нам и нужно (отключается через -e). Единственное, что невозможно исправить без дополнительного программирования, это обрезание лидирующих и завершающих пробелов в полях (обратите внимание на телефон Matthew Tucker). Через разделитель это сделать нельзя, потому что имя у нас строится из двух слов. Кроме того, в заголовках тоже есть пробелы.

Своя реализация утилиты column

Код следующей функции написан на языке интерпретатора и позволяет рисовать таблицы. Код функции написан по меркам Bash очень сложно; местами автор просто продублировал некоторые фрагменты. Вероятно этот код не будет работать корректно во всех версиях Bash, но по крайней мере в Bash 4.4.19(1)-release он работает правильно.

Функция получилась такой большой из-за стремления наделить ее множеством опций для рисования таблиц в разных стилях. В отличие от команды column, этой функции нужно знать заранее сколько заголовков будет в таблице. Пользователь передает настройки каждого заголовка с помощью следующих опций:

  • --add-column <текст заголовка>. Объявляет новую колонку. Некоторые опции могут вызываться только следом за этой (обычно из контекста понятно, какие это опции). У этой опции есть обязательный аргумент в виде текста для заголовка столбца. Строка заголовка в принципе может быть пустой и содержать пробелы.
  • --column-alignment-title <left|center|right>. Задает выравнивание текста в заголовке столбца. По умолчанию текст выравнивается по левому краю.
  • --column-alignment <left|center|right>. Задает выравнивание содержимого столбца. По умолчанию текст выравнивается по левому краю.
  • --column-width <число>. Задает максимальную ширину столбца в символах. По умолчанию это значение равно 20. В функции не реализовано вычисление оптимальной ширины, поэтому вы должны брать ширину с некоторым запасом. Если данные будут превышать максимальную ширину, то это будет приводить к перекосам.

Обратите внимание на то, что опции с настройками колонки применяются к последней объявленной колонке.

Следующие опции настраивают таблицу в целом и могут быть в любом месте:

  • --underline-titles <yes|no>. Включает подчеркивание заголовков символом. По умолчанию значение этой опции no.
  • --underline-titles-char <символ>. Позволяет указать каким символом подчеркивать заголовки. По умолчанию это символ тире (-). Эта опция будет иметь эффект, если режим подчеркивания заголовков включен.
  • --sorting-column <синтаксис опции -k команды sort>. Позволяет указать колонку (по умолчанию 1), по которой нужно сортировать данные. Эта опция имеет эффект, если опция сортировки включена. С точки зрения реализации значение параметра передается транзитом опции -k утилиты sort.
  • --default-width-between-columns <число>. Позволяет определить ширину между колонками. По умолчанию это значение равно 2.
  • --default-column-width <число>. Позволяет задать максимальную ширину колонки по умолчанию. Это значение будет использоваться, если вы не указали ширину некоторой колонки явно. По умолчанию это значение равно 20.
  • --default-left-margin <число>. Позволяет задать отступ всей таблицы от левого края экрана. По умолчанию это значение равно 2.
  • --sorting-options <строка>. Функция использует утилиту sort для сортировки. Введенная строка передается утилите транзитом. Таким образом, вы можете управлять сортировкой. По умолчанию утилите передается только опция --dictionary-order. Будьте внимательны: из-за особенностей реализации валидации значений параметров, строку нужно начинать хотя бы одним пробелом.
  • --default-data-delim <строка>. Позволяет задать другой разделитель полей. По умолчанию это IFS.
  • --hide-header. Переключатель. Позволяет отключить вывод шапки таблицы. По умолчанию вывод шапки таблицы включен.
  • --crop-to-width. Переключатель. Позволяет обрезать содержимое колонок по их максимальной ширине. По умолчанию данные выводятся в колонку как они есть, но без начальных и завершающих пробелов.
  • --disable-sort. Переключатель. Позволяет отключить сортировку. По умолчанию сортировка включена. Вы можете заметить, что даже с небольшими данными таблица будет рисоваться с небольшой паузой в начале — это работает сортировка. Если она вам не требуется, то отключите ее этой опцией.

Данные функции следует передавать через стандартный дескриптор ввода. Если не указать дескриптор, то функция зависнет на вызове read, поэтому будьте внимательны с этим. Функция работает следующим образом: она читает строку и разбивает ее по разделителю на части, затем каждую часть она пытается положить в свою колонку. Если входящих данных больше чем колонок, то функция будет переносить их на следующую строку таблицы, не нарушая общее количество колонок. По этой причине функцией можно пользоваться для разбивки текста по указанному числу колонок.

Напротив, если данных окажется меньше чем колонок, то функция оставит оставшиеся колонки на строке незаполненными.

Код
table() {
    local -A __settings=(
        ['underline-titles']='no'
        ['underline-titles-char']='-'
        ['sorting-column']='1'
        ['sorting-options']='--dictionary-order'
        ['default-column-width']='20'
        ['default-width-between-columns']='2'
        ['default-left-margin']='2'
        ['hide-header']='no'
        ['crop-to-width']='no'
        [col_set]=''
    )
    local -a __titles=()
    local -i cur_col=-1
    local key
    while [[ -n "$1" ]]; do
        key=${1#--}
        case "$1" in
        # Creating columns
        --add-column)
            : $((++cur_col))
            shift
            [[ $1 =~ (^--.*|^-.*) ]] && {
                echo "Wrong value for --${key}. Expected: <string> for the column's title."
                return 1
            }
            __titles+=("$1")
            __settings[col_set]="${__settings[col_set]};"
            ;;
        --column-alignment-title)
            [[ $cur_col -ge 0 ]] || {
                echo "Error: You didn't create any columns. Use '--add-column' first."
                return 1
            }
            shift
            [[ $1 =~ (^--.*|^-.*) || ! $1 =~ (left|right|center) || ${#1} -eq 0 ]] && {
                echo "Wrong value for --${key}. Expected: left|right|center"
                return 1
            }
            __settings[col_set]="${__settings[col_set]}t:${1:0:1},"
            ;;
        --column-alignment)
            [[ $cur_col -ge 0 ]] || {
                echo "Error: You didn't create any columns. Use '--add-column' first."
                return 1
            }
            shift
            [[ $1 =~ (^--.*|^-.*) || ! $1 =~ (left|right|center) || ${#1} -eq 0 ]] && {
                echo "Wrong value for --${key}. Expected: left|right|center"
                return 1
            }
            __settings[col_set]="${__settings[col_set]}a:${1:0:1},"
            ;;
        --column-width)
            [[ $cur_col -ge 0 ]] || {
                echo "Error: You didn't create any columns. Use '--add-column' first."
                return 1
            }
            shift
            [[ $1 =~ (^--.*|^-.*) || ! $1 =~ ^[0-9]*$ || ${#1} -eq 0 ]] && {
                echo "Wrong value for --${key}. Expected: <number>"
                return 1
            }
            __settings[col_set]="${__settings[col_set]}w:${1},"
            ;;
        # Common management
        --underline-titles)
            shift
            [[ $1 =~ (^--.*|^-.*) || ! $1 =~ (yes|no) || ${#1} -eq 0 ]] && {
                echo "Wrong value for --${key}. Expected: yes|no"
            }
            __settings[$key]=$1
            ;;
        --underline-titles-char)
            shift
            [[ $1 =~ (^--.*|^-.*) || ${#1} -eq 0 ]] && {
                echo "Wrong value for --${key}. Expected: <character>"
                return 1
            }
            __settings[$key]=${1:0:1}
            ;;
        --default-width-between-columns | --default-column-width | --default-left-margin)
            shift
            [[ $1 =~ (^--.*|^-.*) || ! $1 =~ ^[0-9]*$ || ${#1} -eq 0 ]] && {
                echo "Wrong value for --${key}. Expected: <number>"
                return 1
            }
            __settings[$key]=${1}
            ;;
        --sorting-column)
            shift
            [[ $1 =~ (^--.*|^-.*) || ${#1} -eq 0 ]] && {
                echo "Wrong value for --${key}. Expected: <string>"
                return 1
            }
            __settings[$key]=${1}
            ;;
        --sorting-options)
            shift
            [[ $1 =~ (^--.*|^-.*) || ${#1} -eq 0 ]] && {
                echo "Wrong value for --${key}. Expected: <string>"
                return 1
            }
            __settings[$key]=${1}
            ;;
        --default-data-delim)
            shift
            [[ $1 =~ (^--.*|^-.*) ]] && {
                echo "Wrong value for --${key}. Expected: <string>"
                return 1
            }
            __settings[$key]=${1}
            ;;
        --hide-header | --crop-to-width)
            [[ ${__settings[$key]} == 'yes' ]] && __settings[$key]='no' || __settings[$key]='yes'
            ;;
        --disable-sort)
            __settings['sorting-options']=''
            ;;
        esac
        shift
    done
    ## Helpers
    __helper_field_drawer() {
        [[ $# -ge 2 ]] || return 1
        local align=${3}
        if [[ ${#align} == 0 ]]; then align='--right'; fi
        local length="$1"
        local f
        case "$align" in
        --right)
            f='%*s'
            printf $f $length "$2"
            ;;
        --left)
            f='%-*s'
            printf $f $length "$2"
            ;;
        --center)
            f='%*s'
            local col=$((($length + ${#2}) / 2))
            printf $f $col "$2"
            printf $f $(($length - $col))
            ;;
        esac
        return 0
    }
    __helper_line_drawer() {
        local c=${1}
        local l=${2}
        local line
        printf -v line '%*s' $l
        line=${line// /$c}
        echo "$line"
    }
    ## Drawing
    local counter=0
    local wdth align
    if [[ ${#__titles[@]} -eq 0 ]]; then
        echo "Error: Bad table: the table has not any columns."
        return 1
    fi
    __settings[col_set]=${__settings[col_set]#?}
    if [[ ${__settings['hide-header']} == 'no' ]]; then
        while read -d ';' || [[ -n $REPLY ]]; do
            wdth=$(echo -n "$REPLY" | grep -oE "w:[[:digit:]]+" | grep -oE "[[:digit:]]+")
            wdth=${wdth:-${__settings['default-column-width']}}
            [[ $wdth -ge ${#__titles[$counter]} ]] || wdth=${#__titles[$counter]}
            align=$(echo -n "$REPLY" | grep -oE "t:[rlc]" | grep -oE "[rlc]")
            if [[ $align == r ]]; then
                align='--right'
            elif [[ $align == c ]]; then
                align='--center'
            else
                align='--left'
            fi
            if [[ $counter -eq 0 ]]; then
                printf "%s%s" "$(__helper_line_drawer ' ' ${__settings['default-left-margin']})" "$(__helper_field_drawer $wdth "${__titles[$counter]}" ${align})"
            else
                printf "%s%s" "$(__helper_line_drawer ' ' ${__settings['default-width-between-columns']})" "$(__helper_field_drawer $wdth "${__titles[$counter]}" ${align})"
            fi
            : $((counter++))
        done <<<"${__settings[col_set]}"
        if [[ ${__settings['underline-titles']} != 'no' && -n ${__settings['underline-titles-char']} ]]; then
            counter=0
            echo
            while read -d ';' || [[ -n $REPLY ]]; do
                wdth=$(echo -n "$REPLY" | grep -oE "w:[[:digit:]]+" | grep -oE "[[:digit:]]+")
                wdth=${wdth:-${__settings['default-column-width']}}
                [[ $wdth -ge ${#__titles[$counter]} ]] || wdth=${#__titles[$counter]}
                align='--left'
                if [[ $((counter++)) -eq 0 ]]; then
                    printf "%s%s" "$(__helper_line_drawer ' ' ${__settings['default-left-margin']})" "$(__helper_line_drawer "${__settings['underline-titles-char']}" $wdth)"
                else
                    printf "%s%s" "$(__helper_line_drawer ' ' ${__settings['default-width-between-columns']})" "$(__helper_line_drawer "${__settings['underline-titles-char']}" $wdth)"
                fi
            done <<<"${__settings[col_set]}"
        fi
    fi
    local line
    local -a arr_col_set
    counter=0
    while read -d ';' || [[ -n $REPLY ]]; do
        arr_col_set[$((counter++))]=$REPLY
    done <<<"${__settings[col_set]}"
    counter=0
    if [[ -n ${__settings['sorting-options']} ]]; then
        local sorter="sort ${__settings['sorting-options']} -k ${__settings['sorting-column']}"
    fi
    if [[ -n $sorter ]]; then
        while IFS= read -r line <&4 || [[ -n $line ]]; do
            counter=0
            local tail="$line"
            while
                IFS="${__settings['default-data-delim']:-$IFS}"
                read -r line tail <<<"$tail"
                [[ -n $line ]]
            do
                (($counter != 0)) || echo
                wdth=$(echo -n "${arr_col_set[$counter]}" | grep -oE "w:[[:digit:]]+" | grep -oE "[[:digit:]]+")
                wdth=${wdth:-${__settings['default-column-width']}}
                align=$(echo -n "${arr_col_set[$counter]}" | grep -oE "a:[rlc]" | grep -oE "[rlc]")
                if [[ $align == r ]]; then
                    align='--right'
                elif [[ $align == c ]]; then
                    align='--center'
                else
                    align='--left'
                fi
                line=${line#"${line%%[![:space:]]*}"}
                line=${line%"${line##*[![:space:]]}"}
                if [[ ${__settings['crop-to-width']} == 'yes' ]]; then
                    line=${line:0:$wdth}
                fi
                if [[ $counter -eq 0 ]]; then
                    printf "%s%s" "$(__helper_line_drawer ' ' ${__settings['default-left-margin']})" "$(__helper_field_drawer $wdth "${line}" ${align})"
                else
                    printf "%s%s" "$(__helper_line_drawer ' ' ${__settings['default-width-between-columns']})" "$(__helper_field_drawer $wdth "${line}" ${align})"
                fi
                : $((counter++))
                (($counter > ${#__titles[@]} - 1)) && {
                    counter=0
                }
            done
        done 4<&0 | $sorter
    else
        while IFS= read -r line <&4 || [[ -n $line ]]; do
            counter=0
            local tail="$line"
            while
                IFS="${__settings['default-data-delim']:-$IFS}"
                read -r line tail <<<"$tail"
                [[ -n $line ]]
            do
                (($counter != 0)) || echo
                wdth=$(echo -n "${arr_col_set[$counter]}" | grep -oE "w:[[:digit:]]+" | grep -oE "[[:digit:]]+")
                wdth=${wdth:-${__settings['default-column-width']}}
                align=$(echo -n "${arr_col_set[$counter]}" | grep -oE "a:[rlc]" | grep -oE "[rlc]")
                if [[ $align == r ]]; then
                    align='--right'
                elif [[ $align == c ]]; then
                    align='--center'
                else
                    align='--left'
                fi
                line=${line#"${line%%[![:space:]]*}"}
                line=${line%"${line##*[![:space:]]}"}
                if [[ ${__settings['crop-to-width']} == 'yes' ]]; then
                    line=${line:0:$wdth}
                fi
                if [[ $counter -eq 0 ]]; then
                    printf "%s%s" "$(__helper_line_drawer ' ' ${__settings['default-left-margin']})" "$(__helper_field_drawer $wdth "${line}" ${align})"
                else
                    printf "%s%s" "$(__helper_line_drawer ' ' ${__settings['default-width-between-columns']})" "$(__helper_field_drawer $wdth "${line}" ${align})"
                fi
                : $((counter++))
                (($counter > ${#__titles[@]} - 1)) && {
                    counter=0
                }
            done
        done 4<&0
        echo
    fi
}
Примеры
sed '1d' "contacts.txt" | table \
    --disable-sort \
    --add-column "NAME" \
    --column-alignment-title center \
    --column-width 40 \
    --column-alignment right \
    --add-column "DATE OF BIRTH" \
    --column-width 14 \
    --column-alignment center \
    --add-column "POSITION" \
    --column-alignment-title center \
    --add-column "PHONE" \
    --column-alignment-title center \
    --column-width 8 \
    --default-data-delim ':' \
    --underline-titles yes
# Без сортировки
                    NAME                    DATE OF BIRTH         POSITION         PHONE  
  ----------------------------------------  --------------  --------------------  --------
                               Alice Brown    1989/04/03    Accountant            555-1268
                             Samanta Smith    1995/12/01    Copywriter            555-1233
                              John Berkley    1969/06/12    Boss                  555-1201
                            Matthew Tucker    1988/11/01    Technician            555-1230

# Уберем опцию --disable-sort
                    NAME                    DATE OF BIRTH         POSITION         PHONE  
  ----------------------------------------  --------------  --------------------  --------
                               Alice Brown    1989/04/03    Accountant            555-1268
                              John Berkley    1969/06/12    Boss                  555-1201
                             Samanta Smith    1995/12/01    Copywriter            555-1233
                            Matthew Tucker    1988/11/01    Technician            555-1230

# Попробуем отсортировать по дням рождений --sorting-column 2
                    NAME                    DATE OF BIRTH         POSITION         PHONE  
  ----------------------------------------  --------------  --------------------  --------
                              John Berkley    1969/06/12    Boss                  555-1201
                               Alice Brown    1989/04/03    Accountant            555-1268
                             Samanta Smith    1995/12/01    Copywriter            555-1233
                            Matthew Tucker    1988/11/01    Technician            555-1230

# Попробуем сдвинуть таблицу немного вправо --default-left-margin 16 и поменяем символ заголовка --underline-titles-char '*'
                                  NAME                    DATE OF BIRTH         POSITION         PHONE  
                ****************************************  **************  ********************  ********
                                            John Berkley    1969/06/12    Boss                  555-1201
                                             Alice Brown    1989/04/03    Accountant            555-1268
                                           Samanta Smith    1995/12/01    Copywriter            555-1233
                                          Matthew Tucker    1988/11/01    Technician            555-1230

# Наконец отключим заголовок --hide-header

                                            John Berkley    1969/06/12    Boss                  555-1201
                                             Alice Brown    1989/04/03    Accountant            555-1268
                                           Samanta Smith    1995/12/01    Copywriter            555-1233
                                          Matthew Tucker    1988/11/01    Technician            555-1230

Анимации

Принцип рисования любой анимации на терминале заключается в следующих моментах:

  • Выбор таймера анимации.
  • Выбор места рисования.

Анимирование происходит всегда по следующей схеме:

  1. Получение импульса на отрисовку.
  2. Сохранение позиции курсора.
  3. Смещение курсора терминала на начальную позицию анимируемой строки. Если анимирование происходит на той же строке, что и текущее положение курсора, то смещение может сопровождаться одновременным затиранием всей строки.
  4. Вывод обновленной информации. Иллюзия анимации происходит потому, что вывод всегда производится в одном и том же формате. Каждый последующий вывод как бы перекрывает собой предыдущий.
  5. Возвращение курсора на прежнюю позицию, на которой он был до получения импульса.

Отметим, что возвращение позиции курсора иногда может быть не обязательно. Обычно возвращение курсора связано с тем, что у вас несколько анимируемых областей, каждая из которых не должна делать предположений о том, что курсор мог быть сдвинут до нее.

Для анимирования вам помогут следующие управляющие последовательности. Для упрощения программирования перемещения курсора, мы будем использовать вспомогательную функцию move_cursor. Данная функция ожидает следующие ключи и аргументы для них:

  • --up [N]. Переместить курсор на N позиций наверх, причем курсор останется на той же колонке. Если N не указана, то сдвигает на 1 позицию.
  • --down [N]. Переместить курсор на N позиций вниз, причем курсор останется на той же колонке. Если N не указана, то сдвигает на 1 позицию.
  • --left [N]. Переместить курсор на N позиций налево в той же строке. Если N не указана, то сдвигает на 1 позицию.
  • --right [N]. Переместить курсор на N позиций направо в той же строке. Если N не указана, то сдвигает на 1 позицию.
  • --up-left [N]. Переместить на N позиций наверх и на первую колонку. Если N не указана, то сдвигает на 1 позицию.
  • --down-left [N]. Переместить на N позиций вниз и на первую колонку. Если N не указана, то сдвигает на 1 позицию.
  • --column [N]. Переместить курсор в колонку N на той же строке. Если N не указана, то сдвигает в нулевую колонку.
  • --pos [N] [M]. Переместить курсор на строку N и колонку M. Если N и M не указаны, то координаты будут [1,1].
  • --top-left. Переместить курсор в верхний левый угол.
  • --top-right. Переместить курсор в верхний правый угол.
  • --bottom-left. Переместить курсор в нижний левый угол.
  • --bottom-right. Переместить курсор в нижний правый угол.
  • --save-pos. Сохранить текущую позицию курсора.
  • --restore-pos. Восстановить последнюю сохраненную позицию курсора.
# Позиция курсора
# ---------------
# Примечание: везде вместо # нужно подставить число
#
#    \e[#A     Передвинуть курсор вверх на указанное число позиций
#    \e[#B     Передвинуть курсор вниз на указанное число позиций
#    \e[#C     Передвинуть курсор вправо на указанное число позиций
#    \e[#D     Передвинуть курсор влево на указанное число позиций
#    \e[#E     Передвинуть курсор вниз на указанное число позиций и поставить его в начало строки
#    \e[#F     Передвинуть курсор вверх на указанное число позиций и поставить его в начало строки
#    \e[#G     Передвинуть курсор в укзанный столбец текущей строки
#    \e[#;#H   Поместить курсор в указанную строку и столбец

move_cursor() {
    local escape_seq
    local -i v1=1 v2=1
    case "$1" in
    --up)
        [[ ! -z $2 ]] && v1=$2
        escape_seq="\e[${v1}A"
        ;;
    --down)
        [[ ! -z $2 ]] && v1=$2
        escape_seq="\e[${v1}B"
        ;;
    --right)
        [[ ! -z $2 ]] && v1=$2
        escape_seq="\e[${v1}C"
        ;;
    --left)
        [[ ! -z $2 ]] && v1=$2
        escape_seq="\e[${v1}D"
        ;;
    --down-left)
        [[ ! -z $2 ]] && v1=$2
        escape_seq="\e[${v1}E"
        ;;
    --up-left)
        [[ ! -z $2 ]] && v1=$2
        escape_seq="\e[${v1}F"
        ;;
    --column)
        [[ ! -z $2 ]] && v1=$2 || v1=0
        escape_seq="\e[${v1}G"
        ;;
    --pos)
        v1=$2
        v2=$3
        escape_seq="\e[${v1};${v1}H"
        ;;
    --top-left)
        escape_seq="\e[0;0H"
        ;;
    --top-right)
        escape_seq="\e[0;$(tput cols)H"
        ;;
    --bottom-left)
        escape_seq="\e[$(tput lines);0H"
        ;;
    --bottom-right)
        escape_seq="\e[$(tput lines);$(tput cols)H"
        ;;
    --save-pos) escape_seq="\e[s" ;;
    --restore-pos) escape_seq="\e[u" ;;
    *)
        return 1
        ;;
    esac
    echo -en "$escape_seq"
}

Для тестирования анимаций мы будем использовать имитацию очень долгого процесса в виде следующей функции

long_process_imitation() {
    echo -n "Doing something important, please wait. "
    sleep ${1:-1}
}

В качестве таймера анимации в наших примерах мы будем использовать функцию слежения за процессом watcher:

#
#  $1 => PID процесса, запущенного в подоболочке
#  Оставшиеся аргументы передаются отрисовщику анимации как есть.
watcher() {
    [[ $# -ge 1 ]] || return 2
    local pid=$1
    shift
    local delay=0.75
    # Для некоторых типов анимаций требуется сохранять промежуточные данные между отрисовками.
    # Следующую переменную анимация может использовать для этих целей.
    unset __SHARED_BUFFER
    __SHARED_BUFFER=''
    tput civis # Обычно курсор скрывают, чтобы его перемещения не мелькали на экране
    while [[ -n "$(ps a | awk '{print $1}' | grep $pid)" ]]; do
        __draw_animation "$@"
        sleep $delay # Засыпаем ненадолго
    done
    __erase_animation
    tput cnorm # Возвращаем видимость курсору
}

Меняя функции __draw_animation() и __erase_animation(), мы предоставляем таймеру разные анимации. Первая функция должна нарисовать анимацию некоторым образом, вторая функция должна стереть анимацию.

Спиннер

Цель
  • Рисование спиннера.
  • Обычно спиннер используют, чтобы показать, что процесс не завис.
Код
__draw_animation() {
    local custom_line=$1      # Строка, выводимая после спиннера
    local spinstr=${__SHARED_BUFFER:-'|/-\'}
    local temp=${spinstr#?}
    local time_line=$(date +%X) # Запрашиваем время для отрисовки его вместе со спиннером
    local line # Анимируемая строка
    # Анимация
    printf -v line ' %s (%c)  %s' "${time_line}" "$spinstr" "$custom_line" # Готовим строку на печать
    move_cursor --save-pos     # Сохраняем текущую позицию курсора
    move_cursor --bottom-left  # Двигаемся на позицию рисования анимации
    tput el                    # Затираем всю строку от предыдущей информации
    printf "%s" "${line}"      # Печатаем строку
    __SHARED_BUFFER=$temp${spinstr%"$temp"}  # Ротируем символы в строке спиннера
    move_cursor --restore-pos
}

__erase_animation() {
    move_cursor --save-pos     # Сохраняем позицию курсора
    move_cursor --bottom-left  # Двигаемся на позицию рисования анимации
    tput el                    # Затираем всю строку от предыдущей информации
    move_cursor --restore-pos
}
Описание

В этом примере спиннер рисуется на последней строке терминала. Отрисовщику можно передать строку, которую он будет отображать за спиннером. Переменная __SHARED_BUFFER используется спиннером, чтобы проворачивать свою палочку: каждая последующая анимация сдвигает строку |/-\ на одну позицию налево, причем выпадающий слева символ переходит в конец строки (|/-\, /-\|, -\|/, \|/- и так до бесконечности). Мы не видим эту строку целиком только потому, что функция печати выводит всегда только самый левый символ этой строки.

Примеры
#!/bin/bash
# ...
# ... Функции рисования и прочий код, показанный выше
# ...
trap '__erase_animation; tput cnorm' EXIT SIGKILL  # Чтобы быть уверенным, что видимость курсору вернется в любом случае
tput clear                      # Затираем экран терминала
(long_process_imitation 10) &   # Запускаем наш процесс
watcher $! "Please wait ..."    # Ждем завершения и включаем анимацию спиннера
# Следующий перевод строки требуется для того, чтобы приглашение отобразилось на новой строке.
echo

Хотя изображение и не может передать анимацию, все будет выглядеть примерно следующим образом в трехстрочном терминале.

Doing something important, please wait.

17:00:13 (\)  Please wait ...

Прогресс-бар

Цель
  • Рисование прогресс-бара.
  • Обычно прогресс-бар показывает сколько работы сценарий уже сделал и сколько еще осталось сделать в процентном соотношении.
Предисловие

Существует большое число подходов к рисованию прогресс-бара. Разница между ними состоит в основном в количестве информации, выдаваемой рядом с ним. Например, если утилита что-то скачивает или загружает, то как правило отображается скорость соединения или число переданных байтов. Сама полоска загрузки может быть представлена различными символами. В основном эти символы подбираются так, чтобы они создавали приятное впечатление при просмотре их на черном экране.

Здесь мы рассмотрим рисование самого простого прогресс-бара, который выводит только процент проделанной работы. Наш прогресс бар будет рисоваться на всю ширину в нижней части экрана. Клиентский код при вызове отрисовщика некоторым образом вычисляет и передает этот процент, а прогресс-бар просто его отображает.

Код
__draw_part() {
    local cha=$1
    local -i len=$2
    [[ $len -ne 0 ]] || return
    local v=$(printf "%-${len}s" "$cha")
    echo -ne "${v// /$cha}"
}

__draw_animation() {
    local -i percentage=${1:-0}
    (( percentage > 100 )) && percentage=100
    local -i bar_size=$(( $(tput cols) - 20 ))
    local -r color_bg="\e[42m"
    local -r color_fg="\e[30m"
    local -r color_restore_bg="\e[49m"
    local -r color_restore_fg="\e[39m"
    local actual_size=$((bar_size * percentage / 100))
    local remainder_size=$((bar_size - actual_size))
    local line=$(
        echo -ne "["
        echo -ne ${color_fg}${color_bg}
        __draw_part '#' $actual_size
        echo -ne ${color_restore_fg}${color_restore_bg}
        __draw_part '.' $remainder_size
        echo -ne "] $percentage%"
    )
    # Анимация
    move_cursor --save-pos
    move_cursor --bottom-left
    tput el
    echo -ne " Progress ${line}"
    move_cursor --restore-pos
}

__erase_animation() {
    move_cursor --save-pos     # Сохраняем позицию курсора
    move_cursor --bottom-left  # Двигаемся на позициию рисования анимации
    tput el                    # Затираем всю строку от предыдущей информации
    move_cursor --restore-pos
}
Описание

С точки зрения программирования, прогресс-бар проще спиннера, так как новая отрисовка не зависит от своего предыдущего состояния. В этой реализации мы просто набираем строку из нужных символов в соответствии с текущим процентом. Набор строки реализуется двумя функциями: __draw_animation() и __draw_part(). Обратите внимание, что функция __erase_animation() никак не изменилась.

Прогресс-бар заполняется символами #, причем они закрашиваются зеленым цветом. Оставшаяся часть заполняется точками. По мере увеличения процента, решетки постепенно заполняют собой все точки. Печать точек позволяет уменьшить эффект пустоты, так как прогресс-бар растягивается на всю ширину.

Примеры

Поскольку данная анимация требует на вход данные в виде процента о проделанной работе, пассивное наблюдение за процессом здесь не подходит. В данном примере мы реализуем пару производитель-потребитель. Производитель будет якобы делать некоторую работу и сообщать о своих успехах потребителю. Для этого он периодически будет отсылать процент через файл.

#!/bin/bash
# ...
# ... Функции рисования и прочий код, показанный выше
# ...
trap '__erase_animation; tput cnorm' EXIT SIGKILL  # Чтобы быть уверенным, что видимость курсору вернется в любом случае
tput civis # Скрываем курсор, чтобы он не мелькал
(
    PROGRESS=0
    FLOOR=5
    RANGE=10
    while (( PROGRESS < 100 )); do
        while [[ $NUMBER -le $FLOOR ]]; do
            NUMBER=$RANDOM
            : $((NUMBER %= $RANGE))
        done
        PROGRESS=$((PROGRESS + NUMBER))
        echo $PROGRESS
        sleep 1
    done
) > >(
    declare -i INPUT
    echo "Do very important things. Please wait ..."
    while read INPUT; do
        __draw_animation "$INPUT"
    done
)
tput cnorm # Возвращаем видимость курсору

В этом примере мы пользуемся тем, что подоболочки связываются с тем же терминалом. Результат работы на трехстрочном терминале будет примерно таким.

Do very important things. Please wait ...

Progress [#########################################..........................................................] 42%



← Команда read