Typer для продакшн: подкатанды, конфиги из файлов и “умные” обработчики ошибок
Покажем, как сделать CLI удобным для пользователей: группировка команд, загрузка настроек, корректные exit-коды и сообщения ошибок.
Содержание
Typer для продакшн: подкатанды, конфиги из файлов и “умные” обработчики ошибок
Typer — один из тех Python-инструментов, который приятно начинаешь “просто чтобы сделать CLI”, а затем внезапно обнаруживаешь, что он упирается в те же вопросы, что и в любом продакшн-проекте: как организовать команды, где хранить конфиги, как отвечать пользователю при ошибках и как корректно завершать процесс (exit code), чтобы CLI можно было надежно встроить в скрипты CI/CD.
В этой статье разберем практическую архитектуру CLI на Typer, ориентированную на продакшн-использование: подкатанды, загрузку настроек из файлов, единый слой валидации и “умные” обработчики ошибок. Приведем рабочие примеры кода и разберем типичные грабли, из‑за которых CLI выглядит «работающим», но не является удобным для пользователей и предсказуемым для автоматизации.
Архитектура CLI: команды как интерфейс, а не как набор функций
В учебных примерах Typer часто показывают структуру:
- функция = команда
- параметры = аргументы/опции
- декоратор
@app.command()= привязка
Но в продакшене “команда” — это часть интерфейса, который должен быть:
- Стабильным (не рушиться при добавлении новых фич).
- Организованным (пользователь понимает, где что).
- Понятным в ошибках (сообщения локализованы, корректные exit-коды, подсказки).
- Интегрируемым (скрипты и пайплайны предсказуемы).
Ключевые элементы продакшн-архитектуры:
- Подкатанды (subcommands) — группировка функциональности.
- Загрузка конфигурации — разделение “кода” и “настроек”.
- Единая модель ошибок — единый способ формировать сообщения и выходной код.
- Согласованная валидация — лучше провалиться раньше, чем получить странное поведение.
Подкатанды в Typer: группируем команды и уменьшаем когнитивную нагрузку
Когда подкатанды действительно нужны
Почти всегда стоит переходить к подкатандам, если у вас больше 5–7 команд, или если команды относятся к разным доменам: например, db migrate, db seed, db status, deploy staging, deploy prod, auth login.
Подкатанды улучшают UX за счет:
- уменьшения длины “плоского” списка команд,
- возможности показывать справку по доменам,
- упрощения будущего расширения.
Базовый шаблон с подкатандами
Ниже — рабочий каркас, где верхнеуровневое приложение держит подкатанды. В реальном проекте так удобнее разнести команды по модулям.
# app/main.py
import typer
from app.commands.db import db_app
from app.commands.deploy import deploy_app
app = typer.Typer(
help="CLI для управления проектом: база данных и деплой."
)
app.add_typer(db_app, name="db", help="Операции с БД")
app.add_typer(deploy_app, name="deploy", help="Деплой приложений")
if __name__ == "__main__":
app()
# app/commands/db.py
import typer
db_app = typer.Typer(help="Команды для миграций и статуса БД")
@db_app.command("migrate")
def migrate(
dry_run: bool = typer.Option(False, "--dry-run", help="Без применения изменений"),
):
"""
Применить миграции.
"""
if dry_run:
typer.echo("Dry run: миграции не будут применены.")
raise typer.Exit(code=0)
typer.echo("Миграции применены (демо).")
# app/commands/deploy.py
import typer
deploy_app = typer.Typer(help="Команды для деплоя")
@deploy_app.command("staging")
def staging(version: str = typer.Option(..., "--version")):
"""
Деплой в staging.
"""
typer.echo(f"Деплой staging: версия {version} (демо).")
Практический нюанс: разделяйте Typer-приложения, а не только функции
Не пытайтесь делать “псевдоподкатанды” через --mode или длинные опции. Subcommand — это не украшение. Это способ:
- держать команды “рядом” с доменной логикой,
- проще документировать,
- легче тестировать: вы проверяете конкретные входы/выходы каждой группы.
Конфиги из файлов: где хранить настройки и как их правильно комбинировать
Вопрос №1 для продакшна: каков источник настроек?
Обычно схема такая:
- Конфигурация по умолчанию (встроенная или в файле).
- Файл конфигурации, указанный пользователем.
- Опции командной строки, которые переопределяют то, что в файле.
Эта “лестница приоритетов” должна быть предсказуемой. Пользователь должен понимать: если я задал --timeout 30, он реально переопределит timeout=10 из файла?
Выбор формата конфигурации
На практике для CLI чаще всего выбирают:
- YAML (читаемый, удобен для вложенных структур),
- JSON (строгий, легко парсится),
- иногда TOML (понятный формат, есть нативные библиотеки).
Покажем YAML-подход. Если вам удобнее JSON — принцип тот же.
Модель конфигурации и типизация
Даже если ваш CLI маленький, лучше явно определить структуру конфигурации. Можно использовать dataclasses или pydantic. Для демонстрации — dataclass.
# app/config.py
from dataclasses import dataclass
@dataclass(frozen=True)
class AppConfig:
timeout_seconds: int = 10
environment: str = "dev"
log_level: str = "INFO"
Загрузка конфигурации из файла
# app/config_loader.py
from pathlib import Path
import yaml
from app.config import AppConfig
class ConfigError(Exception):
pass
def load_config(path: Path | None) -> AppConfig:
if path is None:
return AppConfig()
if not path.exists():
raise ConfigError(f"Файл конфигурации не найден: {path}")
try:
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except yaml.YAMLError as e:
raise ConfigError(f"Некорректный YAML в файле {path}: {e}") from e
# Простая маппинг-логика (в продакшене обычно делают валидацию/нормализацию)
return AppConfig(
timeout_seconds=int(raw.get("timeout_seconds", 10)),
environment=str(raw.get("environment", "dev")),
log_level=str(raw.get("log_level", "INFO")),
)
Слой объединения: файл + опции CLI
Typer удобен, но он не построит вашу “лестницу приоритетов” за вас. Лучше сделать это явно.
# app/settings.py
from dataclasses import replace
from pathlib import Path
from app.config import AppConfig
from app.config_loader import load_config
def resolve_config(
config_file: Path | None,
timeout_seconds: int | None,
environment: str | None,
log_level: str | None,
) -> AppConfig:
cfg = load_config(config_file)
if timeout_seconds is not None:
cfg = replace(cfg, timeout_seconds=timeout_seconds)
if environment is not None:
cfg = replace(cfg, environment=environment)
if log_level is not None:
cfg = replace(cfg, log_level=log_level)
return cfg
Подключение конфигурации к командам
Вы не обязаны тянуть конфиг во все команды вручную. Но в продакшене удобно сделать базовый общий “контекст” через зависимость (через callback или через фабрику). Typer поддерживает контекст Context.
Пример: добавим опцию --config в каждую группу команд, а внутри резолвим конфиг.
# app/commands/db.py
import typer
from pathlib import Path
from app.config import AppConfig
from app.settings import resolve_config
from app.config_loader import ConfigError
db_app = typer.Typer(help="Команды для миграций и статуса БД")
def config_options():
return {
"config_file": typer.Option(
None,
"--config",
"-c",
help="Путь к файлу конфигурации YAML",
exists=True,
file_okay=True,
dir_okay=False,
readable=True,
),
"timeout_seconds": typer.Option(
None, "--timeout", help="Переопределить таймаут (в секундах)"
),
"environment": typer.Option(
None, "--env", help="Переопределить окружение (например, dev/staging/prod)"
),
"log_level": typer.Option(
None, "--log-level", help="Переопределить уровень логов"
),
}
@db_app.command("status")
def status(
config_file: Path = typer.Option(None, "--config", "-c"),
timeout_seconds: int | None = typer.Option(None, "--timeout"),
environment: str | None = typer.Option(None, "--env"),
log_level: str | None = typer.Option(None, "--log-level"),
):
"""
Показать статус БД.
"""
try:
cfg = resolve_config(
config_file=config_file,
timeout_seconds=timeout_seconds,
environment=environment,
log_level=log_level,
)
except ConfigError as e:
# Выше мы вынесли модель ошибок — позже сделаем единый обработчик
raise typer.Exit(code=2) from e
typer.echo(f"Environment: {cfg.environment}")
typer.echo(f"Timeout: {cfg.timeout_seconds}s")
typer.echo(f"Log level: {cfg.log_level}")
typer.echo("DB status: OK (демо).")
Подводные камни
- Приоритет источников: всегда документируйте, что именно перекрывает. Иначе пользователь будет “подкручивать” опции, но не понимать, что они игнорируются из‑за неверной схемы.
- Валидация файла: не полагайтесь на то, что ключи будут корректного типа. Ошибки преобразования должны быть превращены в понятные сообщения.
exists=Trueв Typer: удобно, но его поведение может вызывать ранние ошибки парсинга до ваших обработчиков. Если вы хотите управлять стилем сообщений ошибок — иногда лучше убратьexists=Trueи проверять внутри.
Exit-коды как контракт: предсказуемость для автоматизации
Продакшн-CLI почти всегда используется в сценариях:
- Makefile
- GitHub Actions / GitLab CI
- скрипты деплоя
- пайплайны миграций
Для них важен контракт: какой exit code соответствует какой проблеме.
Рекомендуемая базовая матрица
Пример “разумной” схемы (можете подстроить под домен):
0— успешное выполнение1— общая ошибка выполнения (неизвестная/не классифицированная)2— ошибка в конфигурации (файл отсутствует/невалиден/не соответствует схеме)3— ошибка пользователя (неверные параметры/логика запроса)4— сетевые/внешние зависимости (таймауты, отказ в доступе, сервис недоступен)
Главное правило: одинаковая причина → одинаковый код.
Exit в Typer: лучше не бросать “случайные” исключения
Typer использует исключения, и по умолчанию некоторые ошибки могут приводить к exit code 1. Для контроля вам нужно:
- либо явно выбрасывать
typer.Exit(code=...), - либо перехватывать исключения и управлять кодом в одном месте.
Мы сделаем единый обработчик.
“Умные” обработчики ошибок: единый формат, подсказки и корректные коды
Пользовательский опыт в CLI часто ломается не кодом ошибки, а тем, как она представлена:
- “Traceback (most recent call last)” вместо нормального объяснения.
- Без указания, какой параметр некорректен.
- Непонятно, что делать дальше.
- Ошибка конфигурации выглядит как Python-исключение, а не как проблема в данных.
Мы сделаем обработку на двух уровнях:
- Классификация доменных ошибок (валидатор, конфиг, внешний сервис).
- Единый формат вывода и exit code.
Шаг 1: определим доменные исключения
# app/errors.py
class CLIError(Exception):
"""
Базовая ошибка CLI.
"""
exit_code: int = 1
user_message: str = "Ошибка выполнения"
def __init__(self, message: str | None = None):
super().__init__(message or self.user_message)
class ConfigCLIError(CLIError):
exit_code = 2
user_message = "Ошибка конфигурации"
class ParameterCLIError(CLIError):
exit_code = 3
user_message = "Неверные параметры"
class ExternalCLIError(CLIError):
exit_code = 4
user_message = "Ошибка внешнего сервиса"
Шаг 2: преобразуем существующие ошибки в доменные
Допустим, ваш load_config сейчас кидает ConfigError. Преобразуем.
# app/config_loader.py (фрагмент)
from pathlib import Path
import yaml
from app.config import AppConfig
from app.errors import ConfigCLIError
def load_config(path: Path | None) -> AppConfig:
if path is None:
return AppConfig()
if not path.exists():
raise ConfigCLIError(f"Файл конфигурации не найден: {path}")
try:
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except yaml.YAMLError as e:
raise ConfigCLIError(f"Некорректный YAML в {path}: {e}") from e
try:
return AppConfig(
timeout_seconds=int(raw.get("timeout_seconds", 10)),
environment=str(raw.get("environment", "dev")),
log_level=str(raw.get("log_level", "INFO")),
)
except (TypeError, ValueError) as e:
raise ConfigCLIError(f"Ошибка типов в конфигурации {path}: {e}") from e
Шаг 3: единый обработчик для Typer
В Typer можно перехватывать исключения на уровне приложения. Проще всего — использовать @app.callback() или просто обернуть app() в try/except в __main__. Однако правильнее поддерживать единый формат везде.
Вариант с wrapping в main.py:
# app/main.py
import typer
from app.commands.db import db_app
from app.commands.deploy import deploy_app
from app.errors import CLIError
app = typer.Typer(
help="CLI для управления проектом: БД и деплой."
)
app.add_typer(db_app, name="db", help="Операции с БД")
app.add_typer(deploy_app, name="deploy", help="Деплой приложений")
def main():
try:
app()
except CLIError as e:
# Печатаем на stderr, чтобы пользователи и логгеры могли различать
typer.secho(f"Error: {e}", err=True, fg=typer.colors.RED)
raise typer.Exit(code=e.exit_code) from e
if __name__ == "__main__":
main()
Что это дает
- Никакого traceback для “ожидаемых” ошибок.
Error: ...в одном стиле.- Разные причины → разные exit codes.
- Сторонние ошибки (которые не наследуются от
CLIError) по-прежнему покажут traceback (или их можно тоже классифицировать).
“Умность”: где подсказки, а где — точная причина
Хорошее сообщение — это не “что-то пошло не так”, а минимум:
- что случилось,
- где (параметр/файл),
- что делать (корректная команда/пример).
Например, если файл конфигурации отсутствует, можно добавить “пример как указать путь”:
raise ConfigCLIError(
f"Файл конфигурации не найден: {path}. "
f"Укажите корректный путь через --config."
)
Если параметр --version должен соответствовать regex, сообщите: “ожидается формат X.Y.Z”.
Валидация параметров и “мягкие” проверки до исполнения логики
Typer дает базовую валидацию типов (int, float, bool). Но “умная” валидация — это ваша ответственность.
Пример: пользовательский параметр, который требует контекст
Допустим, команда db migrate принимает --target (версия миграции). Логика может требовать, чтобы --dry-run и --target сочетались с ограничениями: например, при --dry-run нельзя делать --apply.
Если это правило появится только внутри “длинного” кода, пользователь потеряет время и получит странную ошибку.
Лучше проверить в начале:
# app/commands/db.py (фрагмент)
import typer
from app.errors import ParameterCLIError
@db_app.command("migrate")
def migrate(
target: str = typer.Option(None, "--target", help="До какой миграции откатить/применить"),
dry_run: bool = typer.Option(False, "--dry-run"),
):
if dry_run and target is None:
# пример: в вашем домене это правило может быть другим
raise ParameterCLIError("--dry-run требует
Комментарии
Пока нет комментариев