Typer в продакшене: конфигурации, автодополнение bash/zsh и сценарии команд с валидацией
Как превратить учебный CLI в рабочий инструмент: команды с подкомандами, загрузка конфигов, валидация параметров и дружелюбные сообщения об ошибках. Добавим автодополнение и обсудим, как тестировать CLI-логику без привязки к терминалу.
Содержание
Typer в продакшене: конфигурации, автодополнение bash/zsh и сценарии команд с валидацией
Typer — современный фреймворк для построения CLI на Python: он превращает объявление параметров в команду, умеет подкоманды, интегрируется с типами и предоставляет удобный способ описывать аргументы/опции. Но учебный пример из README редко становится «рабочим инструментом» для продакшена: там обычно нет конфигураций, полноценной валидации, дружелюбных ошибок, автодополнения и, что особенно важно, тестируемой архитектуры.
В этой статье разберём, как довести Typer CLI до уровня утилиты, которую можно безопасно использовать в командах: спроектируем команды и подкоманды, добавим загрузку конфигов (и их приоритет), построим валидацию параметров и понятные сообщения об ошибках, подключим автодополнение для bash/zsh, а также обсудим, как тестировать логику команд без привязки к интерактивному терминалу.
Проектирование CLI для продакшена: не «скрипт», а инструмент
Главная ошибка при переходе от учебных примеров к продакшену — смешивание трёх вещей в одном месте:
- Парсинг параметров (Typer/Click-слой).
- Бизнес-логика (что именно делает команда).
- Взаимодействие с окружением (файлы, сеть, внешние процессы, вывод).
Typer позволяет писать компактно, но для продакшена важно разнести ответственность. Иначе вы получите CLI, который трудно тестировать и сопровождать.
Практическая схема:
- В модулях
commands/лежат функции команд Typer: они принимают строго типизированные аргументы и опции. - В модулях
services/живёт бизнес-логика, которая принимает обычные параметры Python и не знает о Typer. - В
config/— загрузка конфигов и нормализация. - В
ui/— утилиты для сообщений об ошибках/выводе, если нужно.
Всё это не отменяет удобство Typer, но делает проект «склеиваемым», а не «монолитным».
Архитектура Typer: команды, подкоманды и разделение слоёв
Начнём с минимального каркаса. Ниже — структура, которую удобно развивать:
mycli/
__init__.py
app.py
commands/
__init__.py
deploy.py
config_cmds.py
config/
__init__.py
loader.py
models.py
services/
__init__.py
deployer.py
ui/
__init__.py
errors.py
Пример: единый app и подключение подкоманд
# app.py
import typer
from commands.deploy import deploy_app
from commands.config_cmds import config_app
app = typer.Typer(
name="mycli",
help="Инструмент для деплоя и управления конфигами.",
)
app.add_typer(deploy_app, name="deploy")
app.add_typer(config_app, name="config")
if __name__ == "__main__":
app()
В Typer Typer() — по сути обёртка над Click. add_typer удобно использовать для логических групп команд: deploy ..., config ..., users ... и т.д.
Конфигурации: загрузка файлов, переменных окружения и приоритет
В продакшене конфиг почти всегда нужен. Вопрос не «нужен ли», а как именно его выбирать и как разрешать конфликты между источниками значений.
Источники конфигурации
Обычно разумный набор:
- Конфиг-файл:
~/.mycli/config.tomlили./config.toml(по контексту). - Файл, указанный через опцию
--config. - Переменные окружения
MYCLI_*. - Аргументы/опции CLI (самый высокий приоритет).
Модель конфигурации (типизация и дефолты)
Пример через pydantic или простые dataclass. Для иллюстрации возьмём dataclass и явную валидацию (можно усилить pydantic).
# config/models.py
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class AppConfig:
environment: str # например: "prod" | "staging"
api_url: str # базовый URL
token: Optional[str] # может отсутствовать
timeout_sec: int = 30
Загрузка конфигурации
Предположим, конфиг в TOML. Можно поддержать YAML/JSON, но TOML удобен для человека и парсится без сюрпризов.
# config/loader.py
from __future__ import annotations
import os
from dataclasses import asdict
from pathlib import Path
from typing import Optional
try:
import tomllib # py3.11+
except ModuleNotFoundError: # pragma: no cover
import tomli as tomllib
from .models import AppConfig
def _default_config_path() -> Path:
return Path.home() / ".mycli" / "config.toml"
def load_config(path: Optional[str] = None) -> dict:
config_path = Path(path).expanduser() if path else _default_config_path()
if not config_path.exists():
return {}
with config_path.open("rb") as f:
data = tomllib.load(f)
return data
def apply_env_overrides(cfg: dict) -> dict:
# пример: MYCLI_ENVIRONMENT, MYCLI_API_URL, MYCLI_TOKEN, MYCLI_TIMEOUT_SEC
mapping = {
"environment": "MYCLI_ENVIRONMENT",
"api_url": "MYCLI_API_URL",
"token": "MYCLI_TOKEN",
"timeout_sec": "MYCLI_TIMEOUT_SEC",
}
out = dict(cfg)
for key, env_var in mapping.items():
if env_var in os.environ:
val: str = os.environ[env_var]
if key == "timeout_sec":
out[key] = int(val)
else:
out[key] = val
return out
def merge_cli_overrides(cfg: dict, **overrides) -> dict:
"""
Клиентские аргументы/опции должны иметь приоритет над файлом и окружением.
overrides: только те ключи, которые реально заданы CLI.
"""
out = dict(cfg)
for k, v in overrides.items():
if v is not None:
out[k] = v
return out
def build_config(
*,
config_path: Optional[str] = None,
environment: Optional[str] = None,
api_url: Optional[str] = None,
token: Optional[str] = None,
timeout_sec: Optional[int] = None,
) -> AppConfig:
file_cfg = load_config(config_path)
file_cfg = apply_env_overrides(file_cfg)
merged = merge_cli_overrides(
file_cfg,
environment=environment,
api_url=api_url,
token=token,
timeout_sec=timeout_sec,
)
# Нормализация и базовая валидация
environment_val = merged.get("environment", "staging")
api_url_val = merged.get("api_url")
timeout_val = merged.get("timeout_sec", 30)
if not api_url_val:
raise ValueError("Не задан api_url. Укажите его в конфиге или передайте --api-url.")
token_val = merged.get("token")
return AppConfig(
environment=environment_val,
api_url=api_url_val,
token=token_val,
timeout_sec=int(timeout_val),
)
Ключевой момент: build_config получает параметры CLI (все опциональные), грузит конфиг и окружение, затем применяет переопределения CLI. Это обеспечивает стабильный приоритет.
Валидация параметров и дружелюбные ошибки
Typer умеет многое автоматически благодаря аннотациям типов, но для продакшена почти всегда нужна дополнительная валидация:
- Проверки допустимых значений (enum).
- Валидация форматов (URL, пути, токены по длине/маске).
- Согласованность параметров (например:
--dry-runнесовместим с--apply, или токен обязателен вprod). - Понятные сообщения об ошибках без стека трейсинга.
Валидация через Enum
# commands/deploy.py (фрагмент)
import typer
from typing import Optional
from enum import Enum
class Environment(str, Enum):
prod = "prod"
staging = "staging"
dev = "dev"
Typer отрисует подсказки и ограничит значения.
Специализированные исключения и обработка
Лучше ловить предсказуемые ошибки отдельно от «неожиданных». Например:
# ui/errors.py
class CLIValidationError(Exception):
"""Ошибки в параметрах пользователя (предсказуемые, без стека)."""
Используем её в командах.
Пример команды deploy с подкомандами и валидацией
Допустим, у нас есть deploy run и deploy rollback. run требует образ и окружение, а rollback требует идентификатор релиза.
# commands/deploy.py
from __future__ import annotations
import typer
from typing import Optional
from enum import Enum
from config.loader import build_config
from services.deployer import Deployer
from ui.errors import CLIValidationError
deploy_app = typer.Typer(help="Команды деплоя.")
class Environment(str, Enum):
prod = "prod"
staging = "staging"
dev = "dev"
def _validate_timeout(timeout_sec: int) -> int:
if timeout_sec < 1 or timeout_sec > 300:
raise CLIValidationError("--timeout-sec должен быть в диапазоне 1..300.")
return timeout_sec
@deploy_app.command("run")
def run(
image: str = typer.Option(..., "--image", help="Docker image (например: org/app:1.2.3)."),
env: Environment = typer.Option(Environment.staging, "--env", help="Среда деплоя."),
config_path: Optional[str] = typer.Option(None, "--config", help="Путь к config.toml."),
api_url: Optional[str] = typer.Option(None, "--api-url", help="Переопределить api_url из конфигов."),
token: Optional[str] = typer.Option(None, "--token", help="Токен для API."),
timeout_sec: int = typer.Option(30, "--timeout-sec", show_default=True),
dry_run: bool = typer.Option(False, "--dry-run", help="Не выполнять, только показать план."),
):
"""
Запустить деплой.
"""
try:
timeout_sec = _validate_timeout(timeout_sec)
cfg = build_config(
config_path=config_path,
environment=env.value,
api_url=api_url,
token=token,
timeout_sec=timeout_sec,
)
# Дополнительная бизнес-валидация
if cfg.environment == "prod" and not cfg.token:
raise CLIValidationError("Для prod нужен токен. Укажите --token или MYCLI_TOKEN.")
Deployer(cfg).deploy(image=image, dry_run=dry_run)
except CLIValidationError as e:
typer.secho(f"Ошибка: {e}", fg=typer.colors.RED, err=True)
raise typer.Exit(code=2)
Тут важны детали:
- Валидация таймаута и бизнес-условия (prod без токена) — отдельными проверками.
CLIValidationErrorпревращается в аккуратное сообщение.- Мы не выводим трассировку — только пользовательскую причину.
Чего избегать
- Не ловите
Exception«на всякий случай» и не превращайте её в одинаковую ошибку. Иначе вы скрываете реальные проблемы. - Не делайте валидацию «внутри» сервиса без контекста. Пользователю полезнее знать, что именно из его параметров не прошло валидацию.
Сценарии команд: от «одной функции» к воспроизводимому процессу
Для продакшена CLI должен поддерживать сценарии: «сначала посмотри конфиг», «проверь доступность», «запусти», «сделай откат», «покажи статус».
Хороший паттерн — разбивать сценарии по подкомандам и шагам, чтобы пользователь мог делать частичные действия.
Пример config validate и config print
# commands/config_cmds.py
from __future__ import annotations
import typer
from typing import Optional
from config.loader import build_config
from ui.errors import CLIValidationError
config_app = typer.Typer(help="Управление конфигом.")
@config_app.command("validate")
def validate(
config_path: Optional[str] = typer.Option(None, "--config", help="Путь к config.toml."),
api_url: Optional[str] = typer.Option(None, "--api-url"),
token: Optional[str] = typer.Option(None, "--token"),
env: Optional[str] = typer.Option("staging", "--env"),
):
"""
Проверить, что конфигурация валидна.
"""
try:
cfg = build_config(
config_path=config_path,
environment=env,
api_url=api_url,
token=token,
timeout_sec=None,
)
typer.echo("Конфигурация валидна.")
typer.echo(f"environment={cfg.environment}")
typer.echo(f"api_url={cfg.api_url}")
except ValueError as e:
raise CLIValidationError(str(e))
except CLIValidationError as e:
typer.secho(f"Ошибка: {e}", fg=typer.colors.RED, err=True)
raise typer.Exit(code=2)
@config_app.command("print")
def print_config(
config_path: Optional[str] = typer.Option(None, "--config"),
env: Optional[str] = typer.Option(None, "--env"),
api_url: Optional[str] = typer.Option(None, "--api-url"),
token: Optional[str] = typer.Option(None, "--token"),
):
"""
Показать эффективные параметры (после приоритетов).
"""
cfg = build_config(
config_path=config_path,
environment=env,
api_url=api_url,
token=token,
timeout_sec=None,
)
typer.echo(cfg) # в проде лучше форматировать аккуратно
Такой CLI становится «инструментом управления», а не набором разрозненных команд.
Вывод и коды завершения: дисциплина важнее красоты
В продакшене важны:
- коды возврата (exit codes) для автоматизации;
- предсказуемость формата сообщений;
- отсутствие «лишнего» вывода в stdout при ошибках (ошибки — в stderr).
Typer/Click позволяют управлять этим через typer.secho(..., err=True) и typer.Exit(code=...).
Рекомендуемая схема:
0— успех;2— ошибки параметров/валидации;1— прочие ошибки (например, сетевые/файловые), можно расширить подкатегории.
Автодополнение для bash/zsh: чтобы CLI ощущался «нативным»
Typer поддерживает генерацию скриптов автодополнения (через Click). Это обычно делается командой вида:
mycli --help # посмотреть, есть ли командa автодополнения
Но на практике проще использовать встроенные механизмы Click: app генерирует bash/zsh completion.
В Typer можно добавить команды completion. Часто используют подход:
mycli completion bash > /etc/bash_completion.d/myclimycli completion zsh > ...
Покажем пример, который обычно работает в экосистеме Click/Typer.
Добавление команды completion
# app.py
import typer
from commands.deploy import deploy_app
from commands.config_cmds import config_app
app = typer.Typer(name="mycli")
app.add_typer(deploy_app, name="deploy")
app.add_typer(config_app, name="config")
# Click/ Typer обычно уже имеют поддержку генерации completion,
# но в некоторых версиях её нужно явно прокинуть через command.
# Если у вас есть встроенная поддержка, эта часть не обязательна.
# Для конкретной версии Typer/Click стоит проверить доступность:
# `python -m mycli --help` / `mycli completion --help`.
if __name__ == "__main__":
app()
Дальше по документации CLI обычно используют:
mycli completion bash > /etc/bash_completion.d/mycli
mycli completion zsh > "${fpath[1]}/_mycli"
Подводные камни автодополнения
- Автодополнение зависит от имён команд и опций. Если вы переименуете опции — нужно обновлять скрипт (или хотя бы документировать, как его регенерировать).
- Корректные типы (enum, ограниченные значения) улучшают подсказки: например,
--envбудет подсказывать толькоprod/staging/dev. - Если у вас динамические значения (например, список окружений приходит из сети), автодополнение не «угадает» их. В таком случае обычно делают компромисс: автодополнение для фиксированных параметров + fallback валидация на runtime.
Тестирование CLI-логики без привязки к терминалу
Одна из причин, почему Typer любим: команды легко тестировать. Но важно тестировать не «терминал», а бизнес-логику и контракты интерфейса.
Есть два уровня тестов:
- Юнит-тесты сервисов (
Deployer.deploy(...)) — полностью без Typer. - Интеграционные тесты CLI — проверяем, что команды правильно реагируют на входные аргументы/опции, а ошибки конвертируются в ожидаемый вывод и exit code.
Тестирование сервисов
# services/deployer.py
from dataclasses import dataclass
@dataclass
class Deployer:
cfg: object
def deploy(self, image: str, dry_run: bool = False) -> None:
if dry_run:
# логика построения плана
return
# условно: отправка запроса в API
return
Юнит-тест:
# tests/test_deployer.py
import pytest
from services.deployer import Deployer
from config.models import AppConfig
def test_deploy_dry_run_does_not_require_token():
cfg = AppConfig(environment="prod", api_url="https://example.com", token=None, timeout_sec=30)
d = Deployer(cfg)
# В сервисе можно не валидировать prod/token, это делается на уровне CLI.
d.deploy(image="org/app:1.0.0", dry_run=True)
Если валидировать prod/token на уровне CLI (как в примере), то сервис можно тестировать проще: он делает «работу», а не проверяет параметры пользователя.
Интеграционные тесты Typer: Capturing stdout/stderr
Typer базируется на Click и предлагает тестирование через CliRunner (из typer.testing). Это не «интерактивный терминал», а воспроизводимый запуск команды.
Пример:
# tests/test_cli.py
from typer.testing import CliRunner
import pytest
from app import app
runner = CliRunner()
def test_deploy_run_requires_token_in_prod():
result = runner.invoke(
app,
["deploy", "run", "--image", "org/app:1.0.0", "--env", "prod"],
)
assert result.exit_code == 2
assert "Для prod нужен токен" in result.stderr
def test_deploy_run_dry_run_in_prod_without_token():
result = runner.invoke(
app,
["deploy", "run", "--image", "org/app:1.0.0", "--env", "prod", "--dry-run"],
env={"MYCLI_API_URL": "https://example.com"} # полезно подставить обязательные параметры
)
assert result.exit_code == 0
Тут важно:
runner.invokeзапускает CLI как функцию, без терминала.- Можно передавать
env={...}— это решает проблему «как тестировать без реального окружения». - Сервис не дергает сеть в тестах: его лучше мокать или оставлять только проверку валидации.
Мокирование внешних зависимостей
Если Deployer.deploy делает запросы, в интеграционных тестах лучше мокнуть. Pytest + monkeypatch:
# tests/test_cli_mock.py
from typer.testing import CliRunner
from app import app
runner = CliRunner()
def test_deploy_run_calls_service(monkeypatch):
calls = {}
def fake_deploy(self, image: str, dry_run: bool = False):
calls["image"] = image
calls["dry_run"] = dry_run
# Подменяем метод
monkeypatch.setattr("services.deployer.Deployer.deploy", fake_deploy)
result = runner.invoke(
app,
["deploy", "run", "--image", "org/app:2.0.0", "--env", "staging", "--token", "abc"],
env={"MYCLI_API_URL": "https://example.com"},
)
assert result.exit_code == 0
assert calls["image"] == "org/app:2.0.0"
assert calls["dry_run"] is False
Если используете monkeypatch по месту, убедитесь, что путь к объекту корректен (это зависит от того, как импортирован Deployer в commands/deploy.py).
Проверка содержимого вывода
Для дружелюбных ошибок полезно проверять:
exit_code,- фрагмент текста в
stderr, - отсутствие «лишнего» текста в
stdout.
Это делает поведение стабильным при рефакторинге.
Практические советы по UX: подсказки, форматы и «что делать дальше»
Даже самый правильный CLI будет раздражать, если сообщения об ошибках не подсказывают следующий шаг.
Минимальный UX-чеклист:
- Ошибка должна указывать: что именно не так.
- Сообщение должно говорить: что сделать (указать параметр, исправить диапазон, предоставить токен).
- Не дублируйте то, что Typer уже пишет в usage — лучше добавьте контекст.
Пример дружелюбной ошибки (как в --timeout-sec диапазоне):
Ошибка: --timeout-sec должен быть в диапазоне 1..300.
А вот менее полезное:
Ошибка: validation failed
Продакшен-аспекты, которые часто забывают
Логирование вместо «print»
В реальном инструменте полезно иметь уровни логов: --verbose/--quiet и вывод в файл при необходимости. Typer не заставляет вас использовать logging, но лучше включить стандартную библиотеку logging.
Версионирование и миграции конфигов
Если формат конфига меняется, вы столкнётесь с «почему оно не работает у пользователя». Хорошая практика:
config.versionв файле;- миграция или понятное сообщение «обновите конфиг».
Совместимость автодополнения
Если команда распространяется в организации, где shell completion включают один раз, то изменения в CLI — это изменения в автодополнении. Документируйте команду регенерации в репозитории.
Вывод: учебный CLI → инструмент команды
Чтобы Typer CLI стал пригодным для продакшена, мало красиво описать параметры. Нужно:
- разделить парсинг и бизнес-логику;
- реализовать внятные конфигурации с предсказуемым приоритетом (CLI > env > файл);
- добавить валидацию с исключениями уровня «ошибка пользователя» и дружелюбными сообщениями;
- построить сценарии через подкоманды;
- подключить автодополнение bash/zsh для удобства ежедневной работы;
- тестировать логику без терминальной зависимости через
CliRunnerи юнит-тесты сервисов.
Если вы хотите глубже разобраться в «практической стороне» Typer — от композиции команд до тонкостей настройки и тестирования — в качестве следующего шага можно посмотреть курс Typer PRO. Он помогает структурировать подход и закрепить лучшие практики на реальных кейсах, но ключевые принципы из этой статьи уже можно применить прямо сейчас к вашему проекту.
Комментарии
Пока нет комментариев