Пишем CLI для разработчика на Python: команды, конфиги и корректные exit codes по стандарту проекта
Построим удобный интерфейс командной утилиты: структура подкоманд, чтение конфигов, валидация входных параметров и единая логика ошибок с понятными exit code. Сфокусируемся на UX для тех, кто запускает CLI в CI и в скриптах.
Содержание
Пишем CLI для разработчика на Python: команды, конфиги и корректные exit codes по стандарту проекта
Командные утилиты для разработчиков живут в «суровой среде»: их запускают из терминала руками, но чаще — в CI, pre-commit хуках, скриптах деплоя, пайплайнах тестирования. В такой реальности UX — это не только «красивый help». Это предсказуемость поведения, стабильные форматы ошибок, корректные сообщения для человека и, главное, строгая семантика exit code для машины.
В этой статье соберём каркас CLI на Python с фокусом на практические моменты:
- структура команд (подкоманды),
- чтение и валидация конфигов,
- единый подход к ошибкам и понятным exit code,
- поведение в CI и скриптах (что критично для команд разработчиков),
- типичные ошибки, из‑за которых «вроде работает, но ломается в пайплайне».
Мы не будем привязываться к конкретному проекту — рассмотрим универсальный дизайн, который легко адаптировать под вашу кодовую базу.
1) Что должен уметь CLI «по стандарту проекта»
Перед кодом полезно договориться о требованиях. Хороший CLI для разработчика обычно должен:
1.1. Быстро отвечать и быть детерминированным
- одинаковые входные параметры → одинаковые ошибки,
- одинаковые ошибки → одинаковые exit codes,
- при фатальных проблемах (не смогли прочитать конфиг, сломалась сеть, нет доступа к файлам) — понятное сообщение, чтобы оператор/CI мог действовать.
1.2. Иметь семантику exit codes, которую понимают люди и автоматизация
Один из наиболее частых антипаттернов: «на всё просто return 1». Это превращает CI в гадание.
Хорошая практика — завести таблицу exit code в README/CONTRIBUTING и держать её неизменной. Например:
0— успех.1— универсальная ошибка выполнения (не “входная”, а внутренняя).2— ошибка использования CLI (неверные аргументы/параметры/формат).3— конфигурация: отсутствует, повреждена, несовместима с версией.4— валидация входных параметров (например, неверный диапазон).5— ошибка сети/внешних сервисов (таймаут, 5xx) — если ваш CLI ходит во внешку.6— файл/файловая система (нет прав, директория не найдена, нет места).7— частичный успех (например, часть чанков обработана, часть нет) — опционально.
Важно: exit code — это API. Любое изменение семантики со временем становится источником «необъяснимых» регрессий в пайплайнах.
1.3. Давать пользователю «следующий шаг»
Сообщение об ошибке должно отвечать на вопросы:
- что не так,
- где именно,
- как исправить,
- (опционально) что предпринять в CI (например, подсказка про переменные окружения).
2) Архитектура: команды, конфиги и единая модель ошибок
Построим утилиту вокруг трёх слоёв:
- CLI слой: парсинг аргументов, структура команд и показ help.
- Сервисный слой: загрузка/валидация конфигурации, выполнение бизнес-логики.
- Слой ошибок и exit codes: единое преобразование исключений в человекочитаемые сообщения и числовые exit codes.
2.1. Командная структура: подкоманды как «глаголы»
Обычно полезная структура:
tool config— управление конфигом (проверка схемы, печать примера),tool run— основной сценарий,tool validate— валидация входных параметров/конфигурации,tool version— версия утилиты.
Подкоманды уменьшают число опциональных параметров и делают UX спокойнее.
2.2. Конфиг: источник правды и приоритеты
Конфиг обычно формируется из нескольких источников:
- YAML/JSON-файл из проекта (например
./tool.yaml), - путь
--config, - переменные окружения
TOOL_*.
Нужно явно определить приоритеты:
- CLI аргументы (если пользователь явно указал)
- переменные окружения
- конфиг-файл
- значения по умолчанию
Если приоритет не прописан, вы получите ситуации «локально работает, в CI — нет».
2.3. Единая модель ошибок
Вместо того чтобы позволять библиотеке парсинга аргументов выбрасывать разрозненные исключения, стоит иметь собственный набор исключений:
CliUsageError— ошибка использования (невалидные аргументы/формат)ConfigError— проблемы с конфигом (не найден, не парсится, несовместимая версия)ValidationError— валидация входных параметров/конфига (например,max_retries< 0)ExternalError— внешние сервисыFilesystemError— доступы/файлы
И в одном месте перевести это в exit code и сообщения.
3) Технический выбор: Typer и почему он удобен именно для UX
Для Python CLI есть несколько вариантов: argparse, click, typer. Мы выберем Typer, потому что:
- он даёт современный DX: аннотации типов → подсказки и help,
- поддерживает подкоманды и опции,
- корректно работает с Python 3 и типами,
- позволяет централизованно обработать исключения и выставлять exit codes.
Если вы хотите глубже разобраться в нюансах Typer (модели данных, более сложные кейсы параметров, паттерны структуры проекта), можно обратить внимание на курс Typer PRO — но сам по себе он не заменяет дизайн exit codes и ошибок, о чём дальше.
4) Реализация: каркас проекта и типизация конфигурации
4.1. Пример структуры файлов
Предположим, проект выглядит так:
mytool/
pyproject.toml
mytool/
__init__.py
cli.py
app.py
config.py
errors.py
logging_config.py
cli.py— Typer команды и опции.config.py— загрузка конфигов, схема и валидация.errors.py— определение исключений и mapping в exit code.app.py— “бизнес” (демо) и вызовы сервисов.logging_config.py— настройка логирования под CI.
4.2. Модель конфигурации
Реалистичный подход — описывать конфиг структурно. Можно использовать pydantic, но если у вас уже есть другой валидатор — адаптируйте идею.
Пример через Pydantic (для демонстрации валидации и удобства):
# mytool/config.py
from __future__ import annotations
from pathlib import Path
from typing import Any, Optional
import yaml
from pydantic import BaseModel, Field, ValidationError
class ToolConfig(BaseModel):
version: int = Field(1, description="Схема конфигурации")
retries: int = Field(3, ge=0, le=10)
timeout_seconds: int = Field(30, gt=0, le=300)
output_dir: Path = Field(..., description="Куда писать результаты")
def load_config(path: Path) -> ToolConfig:
if not path.exists():
raise FileNotFoundError(str(path))
raw: Any
try:
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
except Exception as e: # noqa: BLE001
raise ValueError(f"Не удалось прочитать YAML: {e}") from e
try:
cfg = ToolConfig.model_validate(raw)
except ValidationError as e:
raise ValueError(f"Конфиг не соответствует схеме: {e}") from e
# Сопоставление версии схемы — полезно для долгоживущих проектов.
if cfg.version != 1:
raise ValueError(f"Несовместимая версия схемы конфига: {cfg.version} (ожидалось 1)")
return cfg
Обратите внимание: мы сознательно превращаем ошибки парсинга/валидации в ValueError и FileNotFoundError. Но финальную типизацию ошибок лучше делать в слой errors.py, чтобы CLI единообразно выставлял exit code. В статье ниже мы это сделаем.
5) Единая модель ошибок и exit code mapping
Определим исключения и таблицу exit codes.
# mytool/errors.py
from __future__ import annotations
from dataclasses import dataclass
from typing import Type
class CliError(Exception):
"""Базовое исключение CLI."""
exit_code: int = 1
user_message: str = "Ошибка выполнения"
def __init__(self, message: str | None = None) -> None:
super().__init__(message or self.user_message)
class CliUsageError(CliError):
exit_code = 2
user_message = "Неверное использование CLI"
class ConfigError(CliError):
exit_code = 3
user_message = "Проблема с конфигурацией"
class ValidationError(CliError):
exit_code = 4
user_message = "Ошибка валидации параметров"
class ExternalError(CliError):
exit_code = 5
user_message = "Ошибка внешнего сервиса"
class FilesystemError(CliError):
exit_code = 6
user_message = "Проблема с файловой системой"
@dataclass(frozen=True)
class ErrorView:
exit_code: int
message: str
def to_error_view(exc: Exception) -> ErrorView:
# Нормализуем типы исключений в единый формат.
if isinstance(exc, CliError):
return ErrorView(exit_code=exc.exit_code, message=str(exc) or exc.user_message)
# Примеры "обвязки" для системных исключений:
if isinstance(exc, FileNotFoundError):
return ErrorView(exit_code=6, message=f"Файл не найден: {exc}")
if isinstance(exc, PermissionError):
return ErrorView(exit_code=6, message=f"Нет прав: {exc}")
# Любая неожиданность = внутренняя ошибка.
# В CI полезно дать минимальное сообщение пользователю,
# а детали логировать.
return ErrorView(exit_code=1, message="Внутренняя ошибка. Проверьте логи для деталей.")
Ключевой момент: to_error_view решает, что показывать пользователю и какой exit code отдавать. Подробности ошибок (stack trace) — в логах.
6) Сама CLI-утилита: конфиги, валидация и корректные exit codes
6.1. Настройка логирования под CI
В CI обычно важна предсказуемость формата. Минимальный вариант — в логах текст, а в stdout/stderr — сообщения об ошибках.
# mytool/logging_config.py
from __future__ import annotations
import logging
import sys
def configure_logging(verbosity: int) -> None:
level = logging.WARNING
if verbosity == 1:
level = logging.INFO
elif verbosity >= 2:
level = logging.DEBUG
logging.basicConfig(
level=level,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
stream=sys.stderr,
)
6.2. Простейшее приложение (бизнес-логика)
Для примера сделаем команду run, которая “использует” конфиг и параметры.
# mytool/app.py
from __future__ import annotations
import logging
from pathlib import Path
from .config import ToolConfig
from .errors import ExternalError, FilesystemError, ValidationError
logger = logging.getLogger(__name__)
def run_task(cfg: ToolConfig, *, dry_run: bool, name: str) -> int:
# Валидация входных параметров можно делать тут
# (или на уровне CLI/конфига — где вам удобнее).
if not name.strip():
raise ValidationError("Параметр --name не может быть пустым")
out_dir: Path = cfg.output_dir
if not dry_run:
try:
out_dir.mkdir(parents=True, exist_ok=True)
except OSError as e:
raise FilesystemError(f"Не удалось создать директорию вывода: {out_dir}") from e
logger.info("Старт задачи: name=%s retries=%s timeout=%s dry_run=%s",
name, cfg.retries, cfg.timeout_seconds, dry_run)
if name.lower() == "fail":
# Демонстрация: внешняя/внутренняя ошибка.
raise ExternalError("Симуляция сбоя внешнего шага для демонстрации exit code")
# Возвращаем количество обработанных элементов (для демонстрации).
return 1
6.3. Переходим к CLI: команды, общий обработчик ошибок, валидация
Главная цель — в одном месте перехватывать CliError и выставлять exit code. В Typer для этого можно использовать try/except вокруг app() или обработчик typer.Exit.
Мы сделаем явный “entrypoint” с единым обработчиком:
# mytool/cli.py
from __future__ import annotations
from pathlib import Path
from typing import Optional
import typer
from .app import run_task
from .config import ToolConfig, load_config
from .errors import (
CliError,
CliUsageError,
ConfigError,
ValidationError,
to_error_view,
)
from .logging_config import configure_logging
app = typer.Typer(add_completion=False, help="mytool — CLI для разработчиков")
def resolve_config(
config_path: Optional[Path],
) -> ToolConfig:
"""
Разрешение конфигурации:
- если путь явно задан --config, читаем его;
- иначе пробуем tool.yaml в текущей директории.
"""
if config_path is None:
config_path = Path.cwd() / "tool.yaml"
try:
return load_config(config_path)
except FileNotFoundError as e:
raise ConfigError(f"Не найден конфиг: {config_path}") from e
except ValueError as e:
raise ConfigError(str(e)) from e
except Exception as e: # noqa: BLE001
raise ConfigError(f"Ошибка загрузки конфига: {config_path}") from e
@app.callback(invoke_without_command=False)
def main(
ctx: typer.Context,
verbosity: int = typer.Option(0, "--verbose", "-v", count=True, help="Уровень логирования"),
) -> None:
configure_logging(verbosity)
@app.command()
def version() -> None:
"""Показать версию утилиты."""
# Здесь можно подставить importlib.metadata.version(...)
typer.echo("mytool 0.1.0")
@app.command()
def validate(
config: Optional[Path] = typer.Option(
None, "--config", "-c", exists=False, dir_okay=False, file_okay=True,
help="Путь к конфигу YAML (по умолчанию: ./tool.yaml)"
),
) -> None:
"""Проверить конфигурацию на валидность."""
cfg = resolve_config(config)
# Если дошли сюда — конфиг валиден.
typer.echo(f"OK: config.version={cfg.version}, retries={cfg.retries}, timeout={cfg.timeout_seconds}, output_dir={cfg.output_dir}")
@app.command()
def run(
name: str = typer.Option(..., "--name", "-n", help="Имя задачи для запуска"),
dry_run: bool = typer.Option(False, "--dry-run", help="Не выполнять побочные эффекты"),
config: Optional[Path] = typer.Option(
None, "--config", "-c", help="Путь к конфигу YAML (по умолчанию: ./tool.yaml)"
),
) -> None:
"""Запустить основную задачу."""
# Пример валидации параметра на уровне CLI:
# (можно перенести в app.run_task, но тогда exit code mapping будет одинаковый)
if len(name) > 200:
# Важно: это не “ошибка парсинга аргументов”, а именно “валидация”.
raise ValidationError("Параметр --name слишком длинный (макс. 200 символов)")
cfg = resolve_config(config)
processed = run_task(cfg, dry_run=dry_run, name=name)
typer.echo(f"Done: processed={processed}")
def entrypoint() -> None:
"""
Единая точка входа:
- перехватывает ошибки проекта,
- преобразует в понятный stderr-вывод,
- выставляет корректный exit code.
"""
try:
app() # Typer сам прочитает sys.argv и выполнит команду.
except Exception as exc: # noqa: BLE001
view = to_error_view(exc)
# Для UX: сообщение об ошибке — в stderr.
typer.secho(view.message, err=True, fg=typer.colors.RED)
raise typer.Exit(code=view.exit_code)
if __name__ == "__main__":
entrypoint()
6.4. Почему обработчик ошибок вынесен в entrypoint
Если обрабатывать ошибки “разрозненно” внутри каждой команды, вы быстро получите:
- несогласованные exit codes,
- разные форматы ошибок,
- забытые кейсы (где-то исключение пробрасывается наверх и Typer отдаёт свой exit code — чаще всего это
1).
Единая точка входа решает это системно.
7) Проверка поведения: как выглядит UX в сценариях CI
Рассмотрим типовые сценарии.
7.1. Неверный параметр (валидация)
Допустим, --name слишком длинный. Мы выбросили ValidationError с exit code 4. В CI будет:
- stderr:
Параметр --name слишком длинный (макс. 200 символов) - exit code:
4
Это удобно: вы можете в CI различать «сбой инфраструктуры» (6, 5) и «неправильные параметры» (2, 4) без разбора текста.
7.2. Конфиг отсутствует
Если tool.yaml не найден, resolve_config возбуждает ConfigError → exit code 3. Сообщение содержит путь, что экономит время расследования.
7.3. Внутренняя ошибка
Неожиданное исключение превращается в exit code 1 и сообщение “Внутренняя ошибка…”. При этом stack trace уйдёт в лог благодаря logging. Это тот случай, когда пользователь не обязан видеть весь traceback, но техподдержка — должна.
8) Типичные ошибки при проектировании CLI (и как их избежать)
Ошибка 1: смешивать “ошибку использования” и “ошибку валидации”
Если пользователь передал неправильный формат параметра, это ошибка использования (2) или валидации (4) — но выберите и придерживайтесь подхода. Например:
--timeout abc(не число) → обычно ошибка использования (парсинг).--timeout -5(число, но не по правилам) → ошибка валидации (4).
В Typer парсинг типов частично берёт на себя, но ваши доменные правила лучше явно поднимать как ValidationError.
Ошибка 2: не фиксировать таблицу exit codes в проекте
Без документации таблица начинает “плыть”. Через полгода вы не сможете ответить, почему CI ожидал 3, а фактически получал 1. Минимально — держите таблицу в README или docs/cli.md.
Ошибка 3: выводить ошибки в stdout
CI и скрипты часто читают stdout (например, для парсинга JSON). Поэтому:
- текст ошибок и предупреждений — в stderr,
- данные “для машины” — в stdout (если вы вообще делаете machine-readable режим).
В нашем примере ошибки через typer.secho(..., err=True) попадают в stderr.
Ошибка 4: менять формат сообщений и терять диагностируемость
Если ваше сообщение об ошибке исчезает из логов или меняется каждые 2 релиза, расследование будет дороже. Можно смягчить это:
- сообщения стабильного шаблона (например, “Не найден конфиг: …”),
- детали — в логах.
Ошибка 5: отсутствие режима “машиночитаемого вывода”
Если ваш CLI используется в пайплайнах, часто нужен --output json или отдельная команда report. Тогда важно, чтобы exit code оставался прежним, а формат stdout был предсказуемым. В статье мы не разворачиваем это, но дизайн ошибки/exit code мы уже сделали универсальным.
9) Рекомендации по улучшению: конфиги, переменные окружения и версии схемы
Чтобы CLI реально жил в проектах, стоит добавить несколько практик.
9.1. Версионирование схемы конфигурации
В примере мы держали version в конфиге и жёстко проверяли cfg.version != 1. Альтернатива — делать миграции. Но даже если миграций нет, версия схемы даёт контроль.
9.2. Переменные окружения
Если хотите поддержать переопределения из окружения, полезно:
- определить ключи вида
MYTOOL_RETRIES,MYTOOL_TIMEOUT_SECONDS, - явно указать приоритет (как выше),
- валидировать значения окружения теми же правилами, что и конфиг.
9.3. Строгая валидация путей
Для output_dir важно решать: создавать директории автоматически или требовать существование. В CI чаще удобно создавать, а в “боевом” запуске — аккуратно проверять доступ.
10) Итог: что вы получили и как применить в своём проекте
Мы собрали рабочий каркас Python CLI с предсказуемым поведением:
- подкоманды (
version,validate,run), - конфиг по приоритету (по умолчанию
./tool.yaml, при желании —--config), - валидация входных параметров,
- единое преобразование исключений в понятные exit codes,
- корректный UX для CI: ошибки в stderr, код возврата — стабильный “контракт” для автоматизации.
Главный инженерный смысл здесь не в конкретных командах, а в дисциплине:
- exit codes должны быть документированы и стабильны;
- ошибки должны маппиться в exit codes в одном месте;
- конфиг должен иметь чёткую схему и версию;
- вывод ошибок должен быть пригоден для машинного и человеческого чтения.
Если вы хотите прокачать именно Typer-сторонy (как лучше организовать параметры, типы, сложные сценарии подкоманд и “эргономику” help), хороший следующий шаг — изучить Typer PRO. Но даже без него приведённая в статье архитектура даст вам тот самый уровень надёжности, который требуется CLI в CI и в скриптах.
Комментарии
Пока нет комментариев