Typer для API-подобных CLI: зависимости, подкоманды и формирование “контракта” команды
Покажем, как сделать CLI предсказуемым: единый стиль флагов, повторное использование зависимостей и корректные ответы об ошибках. Статья поможет превратить утилиты в инструменты для команды.
Содержание
Typer для API-подобных CLI: зависимости, подкоманды и формирование “контракта” команды
CLI утилиты часто начинают как набор разрозненных команд «для себя», а заканчиваются тем, что ими пользуются вся команда, пайплайны CI и даже внешние пользователи. В этот момент CLI неизбежно начинают воспринимать как интерфейс — пусть и не HTTP. У такого интерфейса появляются требования к стабильности, предсказуемости и строгим правилам обработки ошибок.
В экосистеме Python одним из практичных инструментов для построения “API-подобных” команд является Typer. Он основан на подсказках типов и позволяет описывать интерфейс так, чтобы он оставался консистентным: единый стиль флагов, повторное использование зависимостей, корректные коды возврата и понятные сообщения об ошибках.
Ниже — разбор того, как проектировать CLI, чтобы он стал воспроизводимым «контрактом», а не набором случайных параметров.
Почему CLI стоит проектировать как API
API обычно строится вокруг нескольких принципов:
- Стабильные точки входа (эндпоинты).
- Предсказуемые форматы входа/выхода.
- Единый стиль параметров и явные соглашения.
- Семантика ошибок: коды, сообщения, иногда структура.
- Версионирование интерфейса.
CLI часто игнорирует часть этих вещей: команды становятся «кривыми» со временем, флаги то называют --dir, то --path, ошибки выводят без структуры, а параметры иногда меняют смысл от релиза к релиза.
Проектирование CLI как API решает практическую проблему: когда утилитой пользуется несколько людей, интерфейс должен быть читабельным и одинаковым. Typer помогает приблизиться к этому за счёт декларативного описания аргументов и более строгой дисциплины разработки.
Базовые принципы “контракта” команды
Под “контрактом” команды будем понимать набор правил, которые остаются стабильными:
Единая номенклатура флагов и аргументов
- Флаги — в формате
--kebab-case:--output-dir,--log-level. - Булевы флаги — в форме
--verbose/--quietлибо--force(а не--isForce). - Позиционные аргументы — короткие и семантические:
SOURCE,TARGET. - Типы определяют поведение:
int/floatвалидируются,Enumограничивает допустимые значения,- пути лучше валидировать до запуска (или хотя бы явно сообщать об ошибке).
Typer поддерживает декларативность: типы и ограничения выносятся в сигнатуры, а CLI-генерация следует за ними.
Предсказуемое поведение --help
Каждая команда должна иметь:
- корректные описания параметров,
- подсказку о типах и допустимых значениях,
- указание единиц измерения (если есть),
- пример (опционально, но полезно).
Нормированные коды возврата
CLI как интерфейс должен уметь различать “что пошло не так”:
0— успех,1— общая ошибка,2— ошибка использования (например, неверные аргументы),3+— доменные/инфраструктурные ошибки (например, сеть, авторизация, отсутствие ресурса).
В Typer корректные коды задаются через исключения typer.Exit и управление обработкой ошибок.
Зависимости в Typer: повторное использование как дизайн-решение
В API-подходе зависимости — это «инфраструктурный слой», который должен повторно использоваться между эндпоинтами/командами. В CLI это может быть:
- создание клиента для сервиса (например, HTTP/gRPC),
- подключение к БД,
- загрузка конфигурации,
- выбор окружения (dev/stage/prod),
- построение логгера,
- получение контекста авторизации.
Typer позволяет использовать механизм зависимостей через параметры, которые подготавливаются функциями. На практике удобно оформить зависимости так, чтобы они:
- имели один вход (например, параметры CLI),
- имели один выход (объект/контекст),
- были одинаковы для всех команд.
Пример: единый контекст приложения
Допустим, у нас есть CLI для работы с проектом: команды sync, status, cleanup. Все они требуют:
- чтение конфигурации,
- настройку логгирования,
- создание клиента API.
from dataclasses import dataclass
from enum import Enum
from typing import Optional
import logging
import typer
app = typer.Typer(add_completion=False)
class Env(str, Enum):
dev = "dev"
stage = "stage"
prod = "prod"
@dataclass(frozen=True)
class AppContext:
env: Env
log_level: str
config_path: str
api_base_url: str
# Здесь можно держать HTTP-клиент/сессию/БД-коннект и т.д.
def load_config(config_path: str) -> dict:
# Заглушка: здесь реальная загрузка YAML/JSON/TOML
return {"dummy": True}
def api_base_for_env(env: Env) -> str:
return {
Env.dev: "https://dev.example.local",
Env.stage: "https://stage.example.local",
Env.prod: "https://api.example.com",
}[env]
def configure_logger(log_level: str) -> None:
logging.basicConfig(
level=getattr(logging, log_level.upper(), logging.INFO),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
def get_ctx(
env: Env = typer.Option(Env.dev, "--env", help="Environment: dev/stage/prod"),
log_level: str = typer.Option("INFO", "--log-level", help="Python logging level"),
config_path: str = typer.Option("config.toml", "--config", help="Path to config file"),
) -> AppContext:
configure_logger(log_level)
_ = load_config(config_path)
return AppContext(
env=env,
log_level=log_level,
config_path=config_path,
api_base_url=api_base_for_env(env),
)
Здесь мы явно формируем AppContext. Важно: зависимость get_ctx выглядит как “поставщик” инфраструктуры. Далее команды будут использовать её как параметр.
Подключение зависимостей к подкомандам
В Typer можно строить подкоманды, и зависимости должны быть общими. Самый простой путь — делать общий префикс в виде typer.Typer() и выносить контекст в параметры родительских команд. Однако не всегда хочется дублировать параметры в каждой команде.
Надёжный подход — держать зависимости в подкоманде, а “сквозные” параметры (env/log-level/config) определить на уровне группы.
groups = typer.Typer(help="Project operations")
@groups.command()
def status(
ctx: AppContext = typer.Depends(get_ctx),
) -> None:
"""Show current status."""
typer.echo(f"Status from {ctx.api_base_url} (env={ctx.env})")
@groups.command()
def sync(
ctx: AppContext = typer.Depends(get_ctx),
dry_run: bool = typer.Option(False, "--dry-run", help="Do not change anything"),
) -> None:
"""Synchronize local state with remote."""
if dry_run:
typer.echo("Dry run enabled")
typer.echo(f"Syncing... (base_url={ctx.api_base_url})")
Обратите внимание на ключевую мысль: команды не должны заниматься инфраструктурой. Их задача — доменная логика. Инфраструктура поставляется через зависимости.
Что выигрывает команда разработки
- Упрощается тестирование: зависимости можно подменять.
- Параметры CLI не расходятся: они определены один раз.
- Ошибки подготовки контекста обрабатываются единообразно (например, неверный config_path).
Подкоманды как “эндпоинты”: структура, имена, семантика
Когда CLI растёт, подкоманды становятся эквивалентом набора “эндпоинтов” — каждое действие должно иметь чёткое назначение и предсказуемый ввод/вывод.
Деление на подкоманды по смыслу, а не по технике
Пример плохой структуры: cli sync, cli download, cli fetch, где все делают “почти одно и то же”, но различаются нюансами реализации.
Пример хорошей структуры:
resource sync— приводит состояние к желаемому;resource status— показывает текущее состояние;resource cleanup— удаляет мусор по правилам.
Typer позволяет удобно организовать это в отдельные группы.
resources = typer.Typer(help="Resource management")
@resources.command()
def sync(
ctx: AppContext = typer.Depends(get_ctx),
) -> None:
"""Bring remote resource in sync with local desired state."""
...
@resources.command()
def cleanup(
ctx: AppContext = typer.Depends(get_ctx),
max_age_days: int = typer.Option(30, "--max-age-days", min=1, help="Delete items older than N days"),
) -> None:
"""Cleanup old items."""
...
Затем в корневом приложении:
app.add_typer(resources, name="resource")
app.add_typer(groups, name="project")
Почему важно “не размазывать” доменную семантику
Если разные подкоманды начинают повторять одинаковые шаги (например, чтение конфигурации и получение токена), зависимости спасают от дублирования. А если разные команды дублируют семантику параметров — это сигнал к рефакторингу контракта.
Формирование контрактов через типы и ограничения Typer
Typer особенно полезен тем, что “контракт” можно выразить на уровне типов. Это не просто удобство — это защита от некорректного ввода.
Ограничения на Enum
class LogFormat(str, Enum):
json = "json"
text = "text"
@app.command()
def report(
fmt: LogFormat = typer.Option(LogFormat.text, "--format", help="Output format"),
) -> None:
"""Generate report."""
if fmt == LogFormat.json:
typer.echo('{"status":"ok"}')
else:
typer.echo("status: ok")
Пользователь не сможет передать неожиданные значения: Typer заранее покажет список вариантов.
Валидация чисел и строк
Используйте min, max, регулярные ограничения (через кастомные типы) и ограничивайте длину.
@app.command()
def set_quota(
limit: int = typer.Option(..., "--limit", min=1, max=10_000, help="Quota limit"),
) -> None:
"""Set quota limit."""
...
Пути: когда лучше валидировать явно
Typer умеет работать с путями, но полезно помнить: сообщение об ошибке должно быть понятным. Если путь не существует — CLI должен сказать “какой именно параметр” и “что ожидалось”.
Один из паттернов — валидировать в зависимости:
from pathlib import Path
def validate_existing_file(path: str) -> str:
p = Path(path)
if not p.exists() or not p.is_file():
raise typer.BadParameter(f"File does not exist: {path}")
return path
def get_ctx_with_config(
config: str = typer.Option("config.toml", "--config", callback=validate_existing_file),
) -> dict:
return {"config": config}
Ошибки как часть контракта: коды, типы и структура сообщения
В “API-подобном” CLI ошибки — это тоже часть интерфейса. Нельзя оставлять пользователя наедине с трассировкой (если это не debug-режим), а в автоматизированных системах важно, чтобы сообщение было стабильным.
Разделяйте “ошибку ввода” и “ошибку выполнения”
- Ошибки ввода (неверный параметр, отсутствующий файл, несовместимые флаги) — это пользовательские ошибки.
- Ошибки выполнения (сбой сети, таймаут, недоступна БД) — инфраструктурные.
В Typer можно выбрасывать typer.BadParameter для ошибок ввода и управлять исключениями для доменных/инфраструктурных.
Единый обработчик исключений на уровне приложения
Один из практичных подходов — обернуть приложение и перехватывать ожидаемые исключения.
import typer
from typing import Any
class DomainError(Exception):
def __init__(self, message: str, code: int = 1, details: Optional[dict[str, Any]] = None):
super().__init__(message)
self.code = code
self.details = details or {}
@app.callback(invoke_without_command=False)
def main(
ctx: typer.Context,
) -> None:
# Здесь можно настроить окружение, общие параметры и т.п.
pass
@app.command()
def run(
fail: bool = typer.Option(False, "--fail", help="Simulate domain failure")
) -> None:
"""Example command."""
if fail:
raise DomainError("Operation failed", code=3, details={"reason": "simulated"})
typer.echo("ok")
Обработку ошибок можно реализовать через try/except внутри команды, но более системно — через middleware/обёртку. Typer напрямую не предлагает полноценный middleware как в web-framework, однако вы можете держать небольшую утилиту для нормализации ошибок:
import json
import typer
def handle_error(e: Exception, *, json_output: bool = False) -> None:
if json_output:
payload = {"error": str(e), "type": e.__class__.__name__}
# DomainError — дополнительно
if isinstance(e, DomainError):
payload["code"] = e.code
payload["details"] = e.details
typer.echo(json.dumps(payload, ensure_ascii=False), err=True)
raise typer.Exit(getattr(e, "code", 1))
else:
# Текстовый формат: кратко и без деталей стека
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(getattr(e, "code", 1))
Тогда команда:
@app.command()
def run(
fail: bool = typer.Option(False, "--fail"),
json_output: bool = typer.Option(False, "--json", help="Output errors as JSON"),
) -> None:
"""Example command with normalized errors."""
try:
if fail:
raise DomainError("Operation failed", code=3, details={"reason": "simulated"})
typer.echo("ok")
except Exception as e:
handle_error(e, json_output=json_output)
Зачем это важно: если ваш CLI запускается в автоматизации, команда --json может быть ключом к стабильному парсингу ошибок.
Ошибки и “контракт” по сообщению
Даже текстовые ошибки должны быть:
- предсказуемыми по структуре,
- короткими,
- с указанием имени параметра или действия (если это ошибка ввода).
Избегайте “размытых” сообщений вроде “something went wrong”. Лучше: Invalid value for --limit: expected >=1.
Вход/выход как контракт: форматы результатов и режимы
API-подход подразумевает, что CLI иногда возвращает данные в машине-читаемом виде. Это особенно актуально, если CLI используется как часть пайплайна.
--json как универсальная опция
Обычно стоит:
- сделать флаг
--json/--no-jsonили просто--json, - структурировать успешный ответ тоже (не только ошибки),
- обеспечить стабильную схему.
Пример для статуса:
@app.command()
def status(
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
env: Env = typer.Option(Env.dev, "--env"),
) -> None:
"""Show system status."""
# Заглушка
data = {"env": env.value, "service": "ok"}
if json_output:
typer.echo(__import__("json").dumps(data, ensure_ascii=False))
else:
typer.echo(f"env={env.value} service=ok")
Стабилизация интерфейса: как не “сломать” CLI
Как и API, CLI должен меняться осторожно.
Не меняйте смысл флагов без версии
- Если вы поменяли семантику параметра — добавьте новый флаг/команду и оставьте старый хотя бы на время.
- Для крупных изменений добавьте
--versionили версионирование в имени группы.
Подумайте о backward-compatible поведении
- Если команда раньше принимала
--dry-runи игнорировала некоторые параметры, сохраните поведение. - При удалении флага — выдавайте предупреждение в текстовом режиме (и код ошибки в режиме политики/CI).
Документация и help — это не “после”
Typer генерирует справку автоматически, но качество справки зависит от того, насколько правильно вы описали параметры: help=..., metavar, show_default, min/max, choices.
Типовые ошибки при проектировании CLI на Typer
Ниже — список проблем, которые встречаются в реальных проектах чаще, чем кажется.
1) Дублирование зависимостей по командам
Когда каждая команда отдельно “создаёт клиент” и читает конфиг, вы получаете:
- расходящиеся параметры,
- несовпадающие сообщения об ошибках,
- усложнение поддержки.
Решение: вынесите подготовку контекста в зависимости.
2) Разные команды используют разные соглашения флагов
Например, одна команда ожидает --config, другая --conf-file. В итоге пользователи и скрипты страдают.
Решение: единый слой опций на уровне группы/контекста.
3) Неявные ошибки и трассировки
Если в консоль падает stack trace, автоматизация ломается
Комментарии
Пока нет комментариев