Сборка CLI-утилит как продукта: команды, конфиги, help-text и обработка ошибок
Разберём, как превращать скрипты в удобные CLI-инструменты: структура команд, конфиги, версии, понятные ошибки и документация. Практика на сценариях.
Содержание
Сборка CLI-утилит как продукта: команды, конфиги, help-text и обработка ошибок
CLI-утилиты редко живут в вакууме. Они появляются в цепочках автоматизации, попадают в скрипты CI/CD, становятся «текущей инфраструктурой» для коллег, и в какой-то момент начинают требовать того, чего скрипты обычно не обещают: предсказуемости интерфейса, стабильности аргументов, нормальной документации и ошибок, по которым можно действовать.
В этой статье разберём CLI как продукт: как проектировать команды, как работать с конфигами, как организовать версии, как писать help-text, как строить систему ошибок и какие практические сценарии стоит прогнать, прежде чем выпускать утилиту «в люди». Будет много прикладного: структуры команд, пример конфигурации, договорённости по exit-кодам, типичные ошибки проектирования и рабочие примеры кода.
Команды как продукт: структура, нейминг и «контракты» интерфейса
Договоримся о целевой аудитории и контексте использования
Один и тот же инструмент можно «собрать» как набор хаотичных флагов или как продукт с понятным контрактом. Ключевые различия:
- Кто будет пользоваться? Разработчики настраивают пайплайны, админы запускают утилиты в ночные смены, аналитики запускают команды «по памяти». Интерфейс должен соответствовать уровню пользователя.
- Где инструмент будет жить? В CI важны повторяемость и отсутствие неожиданностей. В локальном окружении важна вменяемая диагностика и удобство автодополнения.
- Как часто меняются требования? Если утилита будет жить годами, заранее проектируем версионирование и обратную совместимость.
Из этих вопросов рождается основной принцип CLI-продукта: интерфейс — это API. Его нельзя «ломать без причины», а любые изменения нужно делать предсказуемо.
Командная модель: tool <command> [args]
Для утилит чаще всего хорошо работает иерархия:
tool— «корень»,<command>— действие (операция),[args]— параметры операции.
Примеры:
tool init— создать конфиг/проект,tool status— проверить состояние,tool run— выполнить процесс,tool config set— изменить параметр конфигурации.
Это проще для пользователя, чем единая куча флагов. Плюс команда позволяет развивать интерфейс без взрывного роста количества опций.
Нейминг: последовательность и предсказуемость
Соблюдайте согласованность:
- Глаголы для команд (
init,run,update,validate) - Единые правила для имён опций:
- короткие (
-c) всегда означают одно и то же, - длинные (
--config) отражают смысл.
- короткие (
- Для параметров выбора используйте очевидные названия:
--profile,--environment,--format.
Идемпотентность и повторяемость
Если команда меняет состояние (создаёт файлы, пишет в базу, меняет конфигурацию), стоит продумать:
- идемпотентна ли она? Например,
initчасто делает «если не существует — создай». - как откатывать изменения? Даже если откат не реализован, интерфейс должен не оставлять пользователя в «полусостоянии». Обязательна транзакционность на уровне операций или хотя бы понятная схема фиксации.
Версионирование интерфейса и данных
У CLI есть две сущности:
- версия самого инструмента (package version),
- версия формата конфигурации/схемы данных.
Минимальные практики:
- команда
tool --versionи вывод формата видаtool 1.8.3 (python 3.12). - для конфигов — явный
schema_versionи миграции (пусть даже простые). - поведение по умолчанию лучше закрепить: например, без
--configберём~/.config/tool/config.toml, но если файла нет — подсказываемtool init.
Конфиги: хранение, приоритеты источников и миграции
Где должен жить конфиг
Чаще всего:
- пользовательский конфиг:
~/.config/<tool>/config.tomlили.yaml, - проектный конфиг:
./<file>внутри репозитория (например,./.tool/config.toml), - переменные окружения как «override».
Главная идея: разделить конфиг на слои и сделать приоритеты прозрачными.
Приоритет источников конфигурации
Рекомендуемая схема (от слабого к сильному):
- конфиг по умолчанию (вшитые дефолты),
- конфиг в проекте (
./.tool/config.toml), - конфиг пользователя (
~/.config/tool/config.toml), - конфиг, указанный явно (
--config path/to/file), - переменные окружения (
TOOL_*), - аргументы командной строки (
tool run --threads 8).
Если приоритеты не фиксировать, пользователь начнёт «искать магию» — почему значение не поменялось.
Формат конфигурации и выбор сериализации
TOML, YAML, JSON — все варианты возможны. Для CLI-продукта важнее другое:
- читабельность для человека,
- поддержка комментариев (TOML/YAML),
- стабильность парсинга.
Если утилита будет активно конфигурироваться руками — TOML часто практичен.
Миграции схемы конфигурации
В какой-то момент конфигурация будет «устаревать». Минимальный подход:
- поле
schema_version, - при загрузке:
- если версия меньше текущей — попытаться мигрировать,
- если версия больше — отказ с пояснением: «конфиг создан новой версией, обновите инструмент».
Help-text и документация: как сделать интерфейс самообъясняющимся
Help-text — это часть UX
Пользователь не обязан читать README, чтобы начать работу. Но он должен:
- понимать, что инструмент умеет,
- видеть минимальные примеры,
- понимать, какие значения ожидаются,
- получать подсказки при ошибках.
Это достигается:
- адекватными описаниями команд,
- осмысленными названиями параметров,
- форматированным help.
Правила написания help-text
- Коротко в одну строку: описывайте назначение команды.
- Детали — ниже: для сложных режимов добавляйте секции.
- Пишите примеры прямо в help:
tool init --forcetool run --config ./config.toml
- Не дублируйте README: help — это оперативная справка.
Auto-completion и форматирование ошибок в help
Если CLI поддерживает shell completion, пользователь быстрее освоится. Многие фреймворки для Python CLI дают это автоматически, но нужно включить генерацию скриптов completion.
Даже без completion — корректный help уже снижает количество «обращений в чат» вида: «не знаю, как задать параметр».
Обработка ошибок: архитектура, exit-коды и сообщения без паники
Почему «плохие ошибки» — это главная причина фрустрации
Пользователь CLI обычно делает одно из двух:
- запускает команду и видит сообщение «что-то пошло не так»,
- пытается понять причину по логам.
Если сообщение:
- не содержит контекст (какая команда? какой файл? какой параметр?),
- не указывает ожидаемое поведение,
- не различает типы ошибок,
то пользователь будет действовать методом тыка — а это дорогая поддержка.
Категории ошибок
Практичная классификация:
- Ошибки конфигурации:
- не найден файл конфигурации,
- неверный формат,
- невалидные значения.
- Ошибки ввода:
- отсутствует обязательный аргумент,
- некорректное значение флага (например,
--threads -1).
- Ошибки среды:
- нет доступа к файлу,
- отсутствуют необходимые зависимости,
- тайм-аут сети.
- Ошибки выполнения:
- операция упала,
- не получилось завершить задачу.
Важно: разные ошибки должны приводить к разным exit-кодам.
Договорённость по exit-кодам
Пример базовой сетки (можно адаптировать):
0— успех,2— ошибка аргументов (usage error),3— ошибка конфигурации,4— ошибка среды/прав доступа,5— ошибка выполнения (runtime).
При этом:
- в stderr пишем диагностическое сообщение,
- stdout — только данные для конвейера (если CLI поддерживает вывод).
Сообщения об ошибках: минимально достаточный контент
Хорошее сообщение обычно содержит:
- краткая причина,
- какие параметры были проблемными,
- что сделать пользователю (action),
- (опционально) как получить подробности
--debug.
Практический сценарий: проектируем утилиту seedmgr
Рассмотрим утилиту, которая помогает поддерживать набор «seed’ов» (конфигураций) для тестовых данных и окружений. Сценарии типичные:
init— создать шаблон конфигурации,validate— проверить конфиг на валидность,run— применить конфиг,config get/set— управлять ключами.
Сразу заложим:
- приоритеты конфигов,
- версионирование схемы,
- единый слой ошибок,
- понятный help-text.
Ниже пример на Python с использованием Typer (проект удобен для CLI, а также поддерживает структурирование команд и help-text).
Структура проекта
seedmgr/
seedmgr/
__init__.py
cli.py
config.py
errors.py
schema.py
migration.py
pyproject.toml
Типы ошибок и exit-коды
seedmgr/errors.py:
from dataclasses import dataclass
@dataclass
class CliError(Exception):
message: str
exit_code: int = 1
hint: str | None = None
class UsageError(CliError):
def __init__(self, message: str, hint: str | None = None):
super().__init__(message=message, exit_code=2, hint=hint)
class ConfigError(CliError):
def __init__(self, message: str, hint: str | None = None):
super().__init__(message=message, exit_code=3, hint=hint)
class EnvironmentError(CliError):
def __init__(self, message: str, hint: str | None = None):
super().__init__(message=message, exit_code=4, hint=hint)
class RuntimeErrorCli(CliError):
def __init__(self, message: str, hint: str | None = None):
super().__init__(message=message, exit_code=5, hint=hint)
Загрузка конфигурации с приоритетами
seedmgr/config.py (пример с TOML; форматы могут быть другими):
from __future__ import annotations
import os
from pathlib import Path
import tomllib # Python 3.11+. Для старых — tomli
from .errors import ConfigError
DEFAULT_SCHEMA_VERSION = 1
def _load_toml(path: Path) -> dict:
try:
with path.open("rb") as f:
return tomllib.load(f)
except FileNotFoundError:
return {}
except Exception as e:
raise ConfigError(
f"Не удалось прочитать конфигурацию: {path}",
hint=f"Проверьте формат TOML. Детали: {e}",
)
def load_config(
config_path: str | None,
schema_version: int = DEFAULT_SCHEMA_VERSION,
) -> dict:
"""
Приоритеты:
1) дефолт
2) проект: ./.tool/config.toml
3) пользователь: ~/.config/seedmgr/config.toml
4) --config path
5) env TOOL_*
6) позже можно переопределять значениями из аргументов командной строки
"""
merged: dict = {
"schema_version": schema_version,
"environment": "local",
"seeds": {},
}
project_cfg = Path.cwd() / ".tool" / "config.toml"
user_cfg = Path.home() / ".config" / "seedmgr" / "config.toml"
merged.update(_load_toml(project_cfg))
merged.update(_load_toml(user_cfg))
if config_path:
explicit = Path(config_path).expanduser().resolve()
merged.update(_load_toml(explicit))
merged["__config_source__"] = str(explicit)
# Env overrides
# Пример: TOOL_ENVIRONMENT=prod
env_prefix = "TOOL_"
for k, v in os.environ.items():
if not k.startswith(env_prefix):
continue
key = k[len(env_prefix):].lower()
# Очень простой маппинг. В реальном проекте лучше делать строгую типизацию.
if key == "environment":
merged["environment"] = v
# Минимальная проверка схемы
if "schema_version" not in merged:
raise ConfigError(
"В конфигурации отсутствует поле schema_version.",
hint="Сгенерируйте конфиг командой `seedmgr init`.",
)
return merged
Миграции схемы (минимальный каркас)
seedmgr/migration.py:
from .errors import ConfigError
def migrate_config(config: dict, current_schema: int) -> dict:
version = config.get("schema_version")
if version == current_schema:
return config
if version is None:
raise ConfigError("Неизвестная версия схемы конфигурации.")
if version > current_schema:
raise ConfigError(
f"Конфиг создан для schema_version={version}, "
f"а текущий seedmgr поддерживает только до {current_schema}.",
hint="Обновите seedmgr до более новой версии.",
)
# Пример миграции: при version 0 -> 1 добавляем недостающие поля
if version == 0 and current_schema == 1:
config = dict(config)
config.setdefault("environment", "local")
config.setdefault("seeds", {})
config["schema_version"] = 1
return config
raise ConfigError(
f"Миграция schema_version={version} -> {current_schema} не реализована.",
hint="Проверьте документацию или создайте новый конфиг командой init.",
)
Клиентский интерфейс: команды, help-text, ошибки
seedmgr/cli.py:
from typing import Optional
import typer
import sys
from .config import load_config, DEFAULT_SCHEMA_VERSION
from .errors import CliError, UsageError, ConfigError, EnvironmentError
from .migration import migrate_config
app = typer.Typer(
add_completion=True,
no_args_is_help=True,
help="seedmgr — утилита для управления конфигами seed’ов и окружений."
)
@app.callback(invoke_without_command=False)
def main(
ctx: typer.Context,
):
# Общий хук можно использовать для логирования/telemetry,
# но тут оставим минимально.
return
@app.command(help="Создаёт шаблон конфигурации в проекте и/или в домашнем каталоге.")
def init(
config_path: Optional[str] = typer.Option(
None,
"--config",
"-c",
help="Путь для записи конфигурации. Если не указан — создаст ./ .tool/config.toml."
),
force: bool = typer.Option(False, "--force", help="Перезаписать существующий конфиг."),
):
cfg_path = config_path
if cfg_path is None:
cfg_path = str((typer.get_app_dir("seedmgr") / ".." / ".tool" / "config.toml").resolve())
# Для примера допустим, что init создаёт файл в указанном месте.
# В реальном коде — продумать структуру директорий.
from pathlib import Path
path = Path(cfg_path)
if path.exists() and not force:
raise UsageError(
f"Файл конфигурации уже существует: {path}",
hint="Используйте --force, если вы уверены.",
)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
"schema_version = 1\n"
"environment = \"local\"\n"
"[seeds]\n"
" example = \"value\"\n",
encoding="utf-8"
)
typer.echo(f"OK: конфиг создан: {path}")
@app.command(help="Проверяет конфигурацию на валидность и совместимость со схемой.")
def validate(
config: Optional[str] = typer.Option(None, "--config", help="Явный путь к конфигурации."),
):
raw = load_config(config_path=config, schema_version=DEFAULT_SCHEMA_VERSION)
# Миграции
cfg = migrate_config(raw, current_schema=DEFAULT_SCHEMA_VERSION)
seeds = cfg.get("seeds", {})
if not isinstance(seeds, dict):
raise ConfigError("Поле [seeds] должно быть таблицей (ключ-значение).")
typer.echo("OK: конфигурация валидна.")
typer.echo(f"environment={cfg.get('environment')}")
@app.command(help="Применяет конфигурацию и выполняет сценарий генерации/обновления seed’ов.")
def run(
config: Optional[str] = typer.Option(None, "--config", help="Явный путь к конфигурации."),
dry_run: bool = typer.Option(False, "--dry-run", help="Не вносить изменения; только проверить план."),
environment: Optional[str] = typer.Option(
None,
"--environment",
help="Переопределить environment
Комментарии
Пока нет комментариев