Learning Platform
Глоссарий Troubleshooting
Урок 19.04 · 22 мин
Средний
BashgetoptsCLIFlagsArguments

getopts: парсинг CLI-флагов

Production-скрипт должен принимать аргументы из командной строки, а не быть набит хардкод-значениями. cleanup.sh /var/log 7 --dry-run гораздо удобнее, чем cleanup.sh с константами в первых 20 строках. CLI-аргументы делают скрипт переиспользуемым в разных env, легко тестируемым (передать другой --input в test), и self-documented через --help.

Bash из коробки даёт getopts — built-in для парсинга short flags (-n value, -v). Для long options (--name value) нет родного решения — нужен либо case-loop, либо внешний getopt(1) (BSD/GNU варианты несовместимы), либо переход на Python с argparse/typer.

В этом уроке: getopts с примерами, валидация, паттерны для long opts, и когда честно сказать «bash больше не подходит, переписываем на Python».


Зачем нужны флаги

Сравни:

# Без флагов:
cleanup.sh /var/log/airflow 7

# С флагами:
cleanup.sh --log-dir /var/log/airflow --retention-days 7 --dry-run

Второй вариант:

  • Самодокументируется — глядя на команду понятно, что значит каждое значение.
  • Гибкий — порядок аргументов не важен, опции опциональны.
  • Безопасный--dry-run явный, не “третий позиционный аргумент равен true”.
  • Расширяемый — добавить --slack-webhook URL ничего не ломает.

getopts: основы

getopts — bash built-in для парсинга single-letter опций.

#!/bin/bash
set -euo pipefail

# Дефолты:
NAME=""
COUNT=1
VERBOSE=false

while getopts "n:c:vh" opt; do
    case $opt in
        n) NAME="$OPTARG" ;;
        c) COUNT="$OPTARG" ;;
        v) VERBOSE=true ;;
        h) echo "Usage: $0 [-n name] [-c count] [-v] [-h]"; exit 0 ;;
        \?) echo "Unknown option: -$OPTARG" >&2; exit 1 ;;
        :) echo "Option -$OPTARG requires argument" >&2; exit 1 ;;
    esac
done

shift $((OPTIND - 1))

# Теперь $@ — позиционные аргументы (после флагов)
echo "Name=$NAME, Count=$COUNT, Verbose=$VERBOSE"
echo "Remaining args: $@"

Запуск:

$ ./script.sh -n Alice -c 5 -v input.txt
Name=Alice, Count=5, Verbose=true
Remaining args: input.txt

$ ./script.sh -vn Alice    # объединение флагов
Name=Alice, Count=1, Verbose=true
Remaining args:

$ ./script.sh -x
./script.sh: illegal option -- x
Unknown option: -x

Спецификация формата

getopts spec string

Формат описания опций в первом аргументе getopts.

n:c:vhSpec string: каждая буква — опция. Двоеточие ПОСЛЕ буквы — опция требует аргумент. Без двоеточия — boolean flag
":n:c:vh"Двоеточие в НАЧАЛЕ включает silent error mode: getopts перестаёт печатать свои error messages на stderr, и неизвестные опции возвращаются как \\? с $OPTARG = неизвестная буква. Без префикса getopts печатает 'illegal option -- x' автоматически

Специальные переменные

  • $OPTARG — значение текущей опции (если есть :).
  • $OPTIND — индекс следующего необработанного аргумента (1-based). После цикла shift $((OPTIND - 1)) сдвинет позиционные аргументы.
  • $OPTERR=0 — выключить автоматический вывод error messages в stderr.

Реальный DE-скрипт с getopts

#!/bin/bash
# fetch_data.sh — fetch data from API, with options
set -euo pipefail
IFS=$'\n\t'

# Defaults:
API_URL=""
OUTPUT_DIR="/tmp"
RETRIES=3
DRY_RUN=false
VERBOSE=false

usage() {
    cat <<'EOF'
Usage: fetch_data.sh -u URL [-o DIR] [-r N] [-d] [-v] [-h]

Options:
  -u URL    API URL to fetch (required)
  -o DIR    Output directory (default: /tmp)
  -r N      Number of retries (default: 3)
  -d        Dry-run mode (no actual writes)
  -v        Verbose logging
  -h        Show this help

Examples:
  fetch_data.sh -u https://api.example.com/users
  fetch_data.sh -u https://api.example.com/users -o /data -r 5 -v
EOF
}

while getopts "u:o:r:dvh" opt; do
    case $opt in
        u) API_URL="$OPTARG" ;;
        o) OUTPUT_DIR="$OPTARG" ;;
        r) RETRIES="$OPTARG" ;;
        d) DRY_RUN=true ;;
        v) VERBOSE=true ;;
        h) usage; exit 0 ;;
        \?) usage >&2; exit 1 ;;
    esac
done

shift $((OPTIND - 1))

# Валидация:
if [ -z "$API_URL" ]; then
    echo "Error: -u URL is required" >&2
    usage >&2
    exit 1
fi

if ! [[ "$RETRIES" =~ ^[0-9]+$ ]]; then
    echo "Error: -r must be a number, got: $RETRIES" >&2
    exit 1
fi

# Логирование (если verbose):
$VERBOSE && echo "URL=$API_URL"
$VERBOSE && echo "OUTPUT_DIR=$OUTPUT_DIR"
$VERBOSE && echo "RETRIES=$RETRIES"
$VERBOSE && echo "DRY_RUN=$DRY_RUN"

# Dry-run early return:
if $DRY_RUN; then
    echo "DRY-RUN: would fetch $API_URL -> $OUTPUT_DIR with $RETRIES retries"
    exit 0
fi

# Реальная работа:
mkdir -p "$OUTPUT_DIR"
curl --fail --retry "$RETRIES" --max-time 30 "$API_URL" > "$OUTPUT_DIR/data.json"
echo "OK: saved $OUTPUT_DIR/data.json"

Запуски:

$ ./fetch_data.sh -h           # help
$ ./fetch_data.sh -u URL       # обязательный -u
$ ./fetch_data.sh -u URL -d    # dry-run сначала
$ ./fetch_data.sh -u URL -o /data -r 5 -v   # production

Long options: --name value

getopts не поддерживает long options. Если нужны --name value — три варианта:

Вариант 1: Самописный case-loop

NAME=""
COUNT=1
VERBOSE=false

while [[ $# -gt 0 ]]; do
    case $1 in
        --name)
            NAME="$2"
            shift 2
            ;;
        --name=*)
            NAME="${1#--name=}"
            shift
            ;;
        --count)
            COUNT="$2"
            shift 2
            ;;
        --count=*)
            COUNT="${1#--count=}"
            shift
            ;;
        --verbose|-v)
            VERBOSE=true
            shift
            ;;
        --help|-h)
            usage
            exit 0
            ;;
        --)
            shift
            break
            ;;
        -*)
            echo "Unknown option: $1" >&2
            exit 1
            ;;
        *)
            break
            ;;
    esac
done

# $@ — позиционные аргументы

Преимущество: чистый bash, нет зависимостей. Поддерживает оба формата (--name VALUE и --name=VALUE). Недостаток: писать руками много.

Вариант 2: GNU getopt(1)

getopt(1) (внешняя утилита, не путать с getopts builtin) поддерживает long options. Но: BSD getopt (macOS) и GNU getopt (Linux) — несовместимы. На macOS нужно brew install gnu-getopt или fallback.

# Linux/GNU getopt:
ARGS=$(getopt -o "n:c:vh" --long "name:,count:,verbose,help" -n "$0" -- "$@")
eval set -- "$ARGS"

while true; do
    case $1 in
        -n|--name) NAME="$2"; shift 2 ;;
        -c|--count) COUNT="$2"; shift 2 ;;
        -v|--verbose) VERBOSE=true; shift ;;
        -h|--help) usage; exit 0 ;;
        --) shift; break ;;
        *) echo "Bug: unhandled $1" >&2; exit 1 ;;
    esac
done

В реальной DE-команде, где скрипты деплоятся на Linux container — GNU getopt OK. Для cross-platform (Linux + macOS dev) — самописный case-loop надёжнее.

Вариант 3: Перейти на Python

Если у тебя 5+ опций, валидация, помощь, autocomplete — bash перестаёт быть удобным. Python с argparse или typer решает это в 30 строках:

#!/usr/bin/env python3
# fetch_data.py
import typer

app = typer.Typer()

@app.command()
def fetch(
    url: str,
    output_dir: str = "/tmp",
    retries: int = 3,
    dry_run: bool = False,
    verbose: bool = False,
):
    """Fetch data from API."""
    ...

if __name__ == "__main__":
    app()

typer автоматически генерирует --help, валидирует типы, поддерживает subcommands. Для скриптов >150 строк или с 5+ опциями — это правильный выбор.

Bash vs Python: где граница
≤50 строкПростой wrapper: запустить пару команд, обработать exit code. getopts достаточно
50-150Средний скрипт с несколькими функциями. Можно bash + case-loop для long opts. Граница начинает чувствоваться
>150Если уже 150+ строк, многоступенчатая логика, парсинг сложных форматов — Python будет проще читать, тестировать, поддерживать
Тестыbats — bash testing framework, есть. Но pytest + python — на порядок удобнее. Если будут unit-тесты — Python почти всегда выгоднее
ДеплойBash есть везде. Python нужно поставить + virtualenv. В Docker контейнере — без разницы. На bare-metal Linux — bash проще ставится
TIP

Эмпирическое правило: если в твоём bash больше 3 case-веток для опций, больше 100 строк или ты хочешь добавить --config-file my.yaml — перепиши на Python. Час времени, экономия часов поддержки в будущем.


Валидация аргументов

После парсинга — обязательная валидация. Не доверяй input.

# Required check:
if [ -z "$API_URL" ]; then
    echo "Error: --url is required" >&2
    exit 1
fi

# Type check (integer):
if ! [[ "$RETRIES" =~ ^[0-9]+$ ]]; then
    echo "Error: --retries must be non-negative integer" >&2
    exit 1
fi

# Range check:
if [ "$RETRIES" -gt 100 ]; then
    echo "Error: --retries too high (max 100)" >&2
    exit 1
fi

# File exists check:
if [ ! -f "$INPUT_FILE" ]; then
    echo "Error: input file does not exist: $INPUT_FILE" >&2
    exit 1
fi

# Directory writable check:
if [ ! -w "$OUTPUT_DIR" ]; then
    echo "Error: output dir not writable: $OUTPUT_DIR" >&2
    exit 1
fi

# Enum check:
case "$ENV" in
    dev|staging|prod) ;;
    *) echo "Error: --env must be dev/staging/prod, got: $ENV" >&2; exit 1 ;;
esac

# URL pattern (примитивно):
if ! [[ "$API_URL" =~ ^https?:// ]]; then
    echo "Error: --url must start with http:// or https://" >&2
    exit 1
fi

В production скриптах validation составляет часто 30-50% кода. Это нормально. Errors at boundary дешевле, чем silent garbage downstream.


—dry-run: производственный паттерн

--dry-run — must-have для DE. Скрипт показывает, что бы он сделал, но не делает. Безопасный preview перед запуском.

# В коде:
maybe_run() {
    if $DRY_RUN; then
        echo "DRY-RUN: $*"
    else
        echo "RUN: $*"
        "$@"
    fi
}

# Использование:
maybe_run rm -rf "$tmpdir/old_data"
maybe_run aws s3 sync "$tmpdir" "s3://bucket/path/"

maybe_run — wrapper-функция, либо логирует, либо реально выполняет. Это убирает if $DRY_RUN boilerplate из каждого места.

dry-run idiom
--dry-runUser тестирует скрипт перед prod-запуском. Видит что бы он сделал. Безопасно даже на real data
maybe_runHelper-функция: если DRY_RUN=true — печатает план, иначе выполняет
без --dry-runРеальный запуск, реальные изменения. После того как пользователь проверил план

—help: usage функция

Каждый скрипт должен поддерживать --help или -h. Это bare minimum для production-grade.

usage() {
    cat <<EOF
Usage: $(basename "$0") -u URL [OPTIONS]

Required:
  -u URL              API URL to fetch

Options:
  -o DIR              Output directory (default: /tmp)
  -r N                Number of retries (default: 3)
  -d, --dry-run       Print what would be done, don't execute
  -v, --verbose       Verbose logging
  -h, --help          Show this help

Examples:
  $(basename "$0") -u https://api.example.com/users
  $(basename "$0") -u https://api.example.com/users -o /data -r 5 -v
  
Environment:
  API_TOKEN           Required: bearer token for API
  SLACK_WEBHOOK       Optional: URL for failure notifications

Exit codes:
  0   Success
  1   Error (invalid args, command failed)
  2   Lock acquisition failed (another instance running)
EOF
}

cat <<EOF — heredoc, удобный multi-line string. Использование $(basename "$0") делает usage стабильным независимо от того, как скрипт вызван.


Подводные камни

1. getopts работает только с single letters

Нельзя getopts "name:" opt. Получишь bash error. Только одна буква на опцию. Это by design POSIX (getopts — POSIX, getopt — GNU extension).

2. OPTIND сохраняется между вызовами

При повторных вызовах getopts в одной функции OPTIND может оказаться 2, а не 1. Перед циклом сбрасывай:

parse_args() {
    OPTIND=1
    while getopts "n:" opt; do
        ...
    done
}

3. Опции с минусом в значении

./script.sh -n "-something"
# getopts может попытаться парсить "-something" как опцию

Решение: использовать -- сепаратор для конца опций:

./script.sh -n value -- positional_arg_with_minus

После -- getopts перестаёт парсить как опции.

4. Bundling (-vn name) с long opts путает

-vn name означает -v -n name. Но --verbose --name name нельзя бандлить. Юзеры путаются. Документируй явно.


Bash 5.3 (2025): read -E

В bash 5.3 добавили read -E — readline-aware input для read. Полезно для interactive prompts:

# Bash 5.3+:
read -E -p "Enter API URL: " url
# Поддерживает emacs/vi keybindings, history, tab-completion

Не для парсинга CLI-флагов, но смежная тема interactive UX в скриптах.


  • Урок 01 (preamble) — обязательное условие для надёжного парсинга.
  • Урок 05 (debugging) — shellcheck подсказывает по getopts использованию.
  • Модуль 16 (bash basics) — basics: позиционные аргументы 1,1, @.
  • Capstone (модуль 20) — cleanup.sh --log-dir ... --retention-days ... --dry-run.

Попробуй сам

  1. Напиши greet.sh:
#!/bin/bash
set -euo pipefail

NAME="World"
TIMES=1

while getopts "n:t:h" opt; do
    case $opt in
        n) NAME="$OPTARG" ;;
        t) TIMES="$OPTARG" ;;
        h) echo "Usage: $0 [-n NAME] [-t TIMES]"; exit 0 ;;
        *) exit 1 ;;
    esac
done

for ((i=0; i<TIMES; i++)); do
    echo "Hello, $NAME!"
done

Запусти: ./greet.sh, ./greet.sh -n Alice, ./greet.sh -n Bob -t 3, ./greet.sh -h.

  1. Добавь валидацию: TIMES должен быть положительным целым числом. На некорректный input — error и exit 1.

  2. Добавь --dry-run flag через case-loop (long opts).

  3. (advanced) Конвертируй скрипт в Python с typer:

import typer

def greet(name: str = "World", times: int = 1, dry_run: bool = False):
    for _ in range(times):
        if dry_run:
            print(f"[dry-run] Would say: Hello, {name}!")
        else:
            print(f"Hello, {name}!")

typer.run(greet)

Сравни читаемость.


Проверка знанийKnowledge check
Junior пишет скрипт `etl.sh` с опциями: -i input file, -o output dir, -d dry-run, -v verbose. После цикла getopts он хочет получить позиционные аргументы. Какой код корректен и почему важен `shift $((OPTIND-1))`?
ОтветAnswer
Полный паттерн:\nbash\nwhile getopts \"i:o:dvh\" opt; do\n case \$opt in\n i) INPUT=\"\$OPTARG\" ;;\n o) OUTPUT=\"\$OPTARG\" ;;\n d) DRY_RUN=true ;;\n v) VERBOSE=true ;;\n h) usage; exit 0 ;;\n \?) usage >&2; exit 1 ;;\n esac\ndone\nshift \$((OPTIND - 1))\n# Теперь \$@ — позиционные аргументы\n\nЗачем shift OPTIND-1: getopts инкрементирует \$OPTIND по мере чтения опций. После цикла OPTIND указывает на ПЕРВЫЙ необработанный аргумент (1-based). shift \$((OPTIND-1)) сдвигает позиционные параметры влево, удаляя обработанные опции, и оставляя \$1, \$2, ... как реальные позиционные. Без shift скрипт продолжит видеть в \$1 первую опцию (например '-i'), что приведёт к багам. После shift: for f in \"\$@\"; do process \"\$f\"; done работает корректно. Это **обязательный** шаг после getopts loop, идёт в каждый production-скрипт.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. В spec-строке getopts `n:c:vh` что означают двоеточия после `n` и `c`?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5