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.
Специальные переменные
$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 больше 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 из каждого места.
—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 в скриптах.
Cross-links
- Урок 01 (preamble) — обязательное условие для надёжного парсинга.
- Урок 05 (debugging) — shellcheck подсказывает по getopts использованию.
- Модуль 16 (bash basics) — basics: позиционные аргументы @.
- Capstone (модуль 20) —
cleanup.sh --log-dir ... --retention-days ... --dry-run.
Попробуй сам
- Напиши
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.
-
Добавь валидацию:
TIMESдолжен быть положительным целым числом. На некорректный input — error и exit 1. -
Добавь
--dry-runflag через case-loop (long opts). -
(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)
Сравни читаемость.