Typer для CLI-утилит: нормальный парсинг аргументов, группировки опций и интерактивные подсказки
Покажем приёмы построения удобного интерфейса команд: типы аргументов, зависимости значений, соглашения по именам, интерактивные подтверждения и единый стиль сообщений об ошибках. На практических примерах соберём CLI, который ведёт себя предсказуемо в тер
Содержание
Typer для CLI-утилит: нормальный парсинг аргументов, группировки опций и интерактивные подсказки
CLI — это не «просто строка запуска» и не набор флагов из случайных примеров из интернета. Для пользователя (и для CI) интерфейс должен быть предсказуемым: аргументы валидируются одинаково, ошибки читаются с первого взгляда, интерактивность не ломает автоматизацию, а группировки опций помогают ориентироваться в команде.
В экосистеме Python одним из самых удобных инструментов для построения CLI стала библиотека Typer (в основе — Click). Она позволяет быстро собрать команду, но при этом не мешает делать «взрослые» вещи: зависимости опций, валидации, единый стиль ошибок, поддержку автокомплита и интерактивные подтверждения.
Ниже — практический разбор того, как проектировать CLI-утилиты на Typer так, чтобы они были комфортны в терминале и надёжны в CI. Пойдем от базовых типов аргументов и соглашений по именам к единообразной диагностике, затем добавим интерактивность, которая корректно отключается в неинтерактивных режимах.
Архитектура CLI на Typer: как мыслить об интерфейсе
Прежде чем писать код, стоит описать поведение команды.
Хороший CLI обычно отвечает на три вопроса:
- Что обязательно? (required arguments/options)
- Что можно менять? (optional options, их значения и ограничения)
- Как пользователь поймёт, что ошибся? (ошибки, подсказки, примеры)
Typer помогает реализовать это структурно:
- аргументы и опции объявляются типами (строка/число/список/файл/enum),
- ограничения можно держать в коде (валидации),
- подсказки формируются автоматически (help),
- ошибки и формат вывода можно стандартизировать.
Но «автоматически» — не значит «всегда правильно». Проблемы часто возникают из-за:
- смешивания имен (
--output-filevs--outputFile), - неоднозначных значений по умолчанию,
- отсутствия зависимостей (например,
--formatбез обязательной опции для модуля), - интерактивных запросов в сценариях, где должен быть чистый non-interactive режим (CI).
Дальше разберем, как это предотвратить.
Типы аргументов и опций: от базовых к практичным
В Typer тип — это не только удобство, но и валидация на уровне CLI.
Строки, числа, булевы флаги
Пример минимальной команды:
import typer
app = typer.Typer()
@app.command()
def init(
project_name: str,
dry_run: bool = typer.Option(False, "--dry-run", help="Не вносить изменения, только показать план"),
timeout: int = typer.Option(30, "--timeout", min=1, help="Таймаут ожиданий в секундах"),
):
typer.echo(f"project_name={project_name}, dry_run={dry_run}, timeout={timeout}")
Ключевые моменты:
project_name— позиционный аргумент. Он обязателен.dry_run— булев флаг: в терминале это будет--dry-run(true) без значения.timeout— числовая опция сmin=1— неправильные значения отсекутся до выполнения бизнес-логики.
Список значений (повторяемые опции)
Если опция может повторяться или принимать несколько значений, Typer поддерживает List[str]:
from typing import List
import typer
app = typer.Typer()
@app.command()
def add(
targets: List[str] = typer.Option(
...,
"--target",
help="Добавить один или несколько target. Можно повторять: --target a --target b",
)
):
typer.echo(f"targets={targets}")
Интерфейс получится естественным:
--targetбез указания хотя бы одного значения будет считаться ошибкой (так как используется...).- можно передать много
--target, либо один--targetс несколькими значениями — зависит от CLI-обвязки; если нужна строгая форма повторов, лучше документировать это в help.
Enums: ограничения на уровне CLI
Самая частая «неприятность» при парсинге — когда пользователь вводит значение, которое нигде не обрабатывается. Enum решает это.
import enum
import typer
class LogLevel(str, enum.Enum):
debug = "debug"
info = "info"
warning = "warning"
error = "error"
app = typer.Typer()
@app.command()
def run(
log_level: LogLevel = typer.Option(LogLevel.info, "--log-level", help="Уровень логирования"),
):
typer.echo(f"log_level={log_level.value}")
Пользователь увидит допустимые значения в сообщении об ошибке. Это резко улучшает UX.
Аргументы «файлы» и их валидация
Typer позволяет принимать пути. Но важно: путь может быть неверным/не существовать. Удобно сделать явную проверку:
from pathlib import Path
import typer
app = typer.Typer()
@app.command()
def build(
config_path: Path = typer.Argument(..., exists=True, dir_okay=False, readable=True),
):
typer.echo(f"Using config: {config_path}")
Подводный камень: флаги валидации (exists, readable) работают на уровне Click/ Typer, но иногда требования зависят от OS (права/символические ссылки). Для надежности в CI лучше иметь тесты, которые проверяют типичные ошибки.
Зависимости между значениями: когда валидация нужна логике, а не только типам
Типы покрывают большую часть ошибок, но зависимости — это «вторая линия обороны». Например:
- если
--mode=remote, то обязателен--host; - если включён
--interactive, то команда должна работать в TTY и не должна выполняться в CI; - если задан
--output, то--formatстановится обязательным (или наоборот).
Typer позволяет делать это в функции команды. Важно — делать проверку как можно раньше и формировать ошибку единообразно.
Пример: взаимоисключающие и взаимообусловленные опции
from pathlib import Path
from typing import Optional
import typer
app = typer.Typer()
def fail(message: str) -> None:
raise typer.Exit(code=2, message=message)
@app.command()
def sync(
source: Path = typer.Argument(..., exists=True),
dest: Optional[Path] = typer.Option(None, "--dest", help="Папка назначения"),
remote: bool = typer.Option(False, "--remote", help="Синхронизировать удаленно"),
host: Optional[str] = typer.Option(None, "--host", help="Хост для remote"),
):
# Взаимообусловленность
if remote and not host:
fail("Опция --host обязательна при включенном --remote")
# Пример взаимоисключения: удалённая синхронизация не использует локальный dest
if remote and dest is not None:
fail("Опция --dest несовместима с --remote")
typer.echo(f"source={source}, dest={dest}, remote={remote}, host={host}")
Здесь есть важный нюанс: typer.Exit с сообщением. Мы ещё вернемся к стилю ошибок и сделаем единый механизм.
Соглашения по именам и структуре интерфейса: чтобы команды «читались»
CLI быстро становится хаотичным, если не соблюдать соглашения. В комьюнити есть устойчивые ожидания:
- длинные опции: kebab-case (
--output-dir,--log-level) - короткие опции (если нужны): обычно
-o,-l, и они соответствуют тем же смысловым параметрам - позиционные аргументы: осмысленные имена (
source,target,config) - единственный смысл на флаг: не смешивайте «режим» и «параметр» в одну опцию, если они логически разные
- help: не дублируйте тип («str») и не пишите очевидное («устанавливает уровень логирования» вместо более полезной формулировки с примерами)
Группировка опций: практичный подход
Есть два уровня «группировки»:
- Логическая — что вместе работает. Это влияет на код и проверки.
- Визуальная — как пользователь это видит в
--help.
Typer по умолчанию показывает списком; «визуальная группировка» обычно достигается через:
- отдельные команды (
app.command("export"),app.command("import")) вместо большого монолита, - группировку через общий аргумент/модуль (например,
--modeи подопции), - аккуратные тексты
help, которые создают семантические блоки.
Когда стоит делать подкоманды
Если комбинаций много, CLI превращается в «панель управления». Тогда лучше выделить подкоманды:
tool users add ...tool users remove ...tool config set ...tool config get ...
Typer поддерживает это естественно: вы объявляете несколько @app.command().
Интерактивные подсказки: комфорт для человека, контролируемость для CI
Интерактивность — вещь тонкая. Пользателю в терминале полезны подтверждения и подсказки. Но в CI такие запросы блокируют пайплайн.
Золотое правило: интерактивность должна быть:
- отключаемой (
--yesили--no-interaction), - зависеть от того, является ли stdout/stderr/ stdin TTY (в идеале),
- иметь предсказуемый fallback (если не интерактивно — вести себя как в «опасном» режиме не должен, лучше падать или принимать безопасное значение).
Простой механизм подтверждений
Typer сам не «создает» диалоги, но удобно использовать input() в паре с проверкой TTY.
import sys
import typer
from typing import Optional
app = typer.Typer()
def is_interactive() -> bool:
return sys.stdin.isatty() and sys.stdout.isatty()
def confirm(action: str, default: Optional[bool] = False) -> bool:
suffix = " [y/N]" if default is False else " [Y/n]"
prompt = f"{action}{suffix}: "
if default is None:
prompt = f"{action} [y/n]: "
while True:
ans = input(prompt).strip().lower()
if ans in ("y", "yes"):
return True
if ans in ("n", "no", ""):
return bool(default)
typer.echo("Пожалуйста, ответьте y или n.")
@app.command()
def delete(
target: str = typer.Argument(...),
yes: bool = typer.Option(False, "--yes", help="Без подтверждения"),
no_interaction: bool = typer.Option(False, "--no-interaction", help="Никогда не задавать вопросов"),
):
if yes:
typer.echo(f"Удаляем {target} (подтверждение пропущено по --yes)")
return
if no_interaction or not is_interactive():
# В CI лучше падать, чтобы избежать случайного удаления.
raise typer.Exit(code=2, message="Отказ: интерактивность запрещена. Добавьте --yes для подтверждения.")
if confirm(f"Подтвердите удаление {target}", default=False):
typer.echo(f"Удаляем {target}")
else:
typer.echo("Отмена пользователем.")
Этот подход:
- в терминале: запрос будет показан,
- в CI без TTY: команда завершится ошибкой и потребует
--yes, - если пользователь сознательно хочет «автоматическое подтверждение»: он задает
--yes.
Подсказки во время ввода (autocomplete и help)
Для Typer полезно:
- хорошо описывать
helpдля каждой опции, - давать осмысленные значения по умолчанию,
- использовать
Enumдля ограниченных наборов значений.
Автодополнение (shell completion) — отдельная тема, но даже без нее хороший help работает почти как «псевдо-интерактивность»: пользователь не должен лезть в документацию, чтобы понять опции.
Единый стиль сообщений об ошибках: чтобы CLI читался, а CI не ломался
Самая раздражающая ситуация — когда ошибки разные по формату: где-то traceback, где-то пустое сообщение, где-то «Error: …», а иногда «stack» вообще неясно почему.
Цель — сделать единообразно:
- консистентный префикс/категория (например,
[error]), - читабельное сообщение для пользователя,
- корректный exit code для CI,
- отсутствие лишних деталей в обычном режиме, но при желании — возможность включить debug.
Практика: централизованный exit
Мы уже использовали функцию fail. Дальше стоит расширить её:
- различать ошибки валидации и внутренние исключения,
- хранить коды (например, 2 для аргументов, 1 для внутренних).
import typer
app = typer.Typer()
def cli_error(message: str, code: int = 2) -> None:
# В Typer можно передать message в Exit.
raise typer.Exit(code=code, message=f"[error] {message}")
@app.command()
def example(
mode: str = typer.Option(..., help="Режим: build или test"),
):
if mode not in {"build", "test"}:
cli_error("Неверный режим. Допустимы: build, test")
typer.echo("ОК")
Это простая схема, но она уже улучшает читаемость.
Границы ответственности: когда ловить исключения
Если вы ловите исключения, то лучше делать это на верхнем уровне, чтобы:
- не маскировать неожиданные ошибки,
- не терять полезные данные для отладки,
- выдавать пользователю «внятное» сообщение без мусора.
В одном файле это выглядит так:
import typer
app = typer.Typer()
@app.command()
def fetch(url: str):
try:
# Допустим, здесь ваш код сети
raise ValueError("network unreachable")
except ValueError as e:
raise typer.Exit(code=2, message=f"[error] Не удалось выполнить запрос: {e}")
В более крупном проекте лучше сделать отдельный «error handler» и логирование.
Собираем CLI-команду «по-взрослому»: зависимости, интерактивность и ошибки
Теперь соберём пример утилиты, которая:
- принимает источники (позиционный аргумент),
- имеет режим локальный/удаленный,
- требует host при remote,
- подтверждает опасное действие,
- имеет единый формат ошибок,
- предсказуемо работает в CI.
Полный пример
from __future__ import annotations
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
import typer
app = typer.Typer(add_completion=True)
@dataclass(frozen=True)
class Config:
source: Path
remote: bool
host: Optional[str]
output_dir: Path
yes: bool
no_interaction: bool
log_level: str
def is_interactive() -> bool:
return sys.stdin.isatty() and sys.stdout.isatty()
def cli_error(message: str, code: int = 2) -> None:
raise typer.Exit(code=code, message=f"[error] {message}")
def confirm(action: str, default: bool = False) -> bool:
# default=False => [y/N]
suffix = " [y/N]" if default is False else " [Y/n]"
prompt = f"{action}{suffix}: "
while True:
ans = input(prompt).strip().lower()
if ans in ("y", "yes"):
return True
if ans in ("n", "no", ""):
return default
typer.echo("Ответьте y или n.")
def build_config(
source: Path,
remote: bool,
host: Optional[str],
output_dir: Path,
yes: bool,
no_interaction: bool,
log_level: str,
) -> Config:
if remote and not host:
cli_error("Опция --host обязательна при включенном --remote")
if (not remote) and host is not None:
cli_error("Опция --host доступна только с --remote")
if output_dir.exists() and not output_dir.is_dir():
cli_error("--output-dir должно быть директорией")
# Допустим, команда опасная: удаляет/перезаписывает output_dir.
# Тогда в CI по умолчанию не подтверждаем.
return Config(
source=source,
remote=remote,
host=host,
output_dir=output_dir,
yes=yes,
no_interaction=no_interaction,
log_level=log_level,
)
@app.command()
def process(
source: Path = typer.Argument(..., exists=True, dir_okay=False, readable=True, help="Входной файл"),
remote: bool = typer.Option(False, "--remote", help="Запускать обработку удаленно"),
host: Optional[str] = typer.Option(None, "--host", help="Хост для удаленного режима"),
output_dir: Path = typer.Option(Path("./out"), "--output-dir", help="Папка для результатов"),
yes: bool = typer.Option(False, "--yes", help="Автоматически подтверждать опасные действия"),
no_interaction: bool = typer.Option(False, "--no-interaction", help="Запретить интерактивные запросы"),
log_level: str = typer.Option("info", "--log-level", help="debug|info|warning|error"),
):
"""
Процессит входной файл и складывает результат в output-dir.
В remote-режиме используется --host.
При необходимости перезаписи спрашивает подтверждение.
"""
cfg = build_config(source, remote, host, output_dir, yes, no_interaction, log_level)
# Пример опасного шага: если output_dir уже существует, мы хотим перезаписать.
if cfg.output_dir.exists():
if cfg.no_interaction or not is_interactive():
if not cfg.yes:
cli_error(
f"output-dir уже существует: {cfg.output_dir}. "
f"В неинтерактивном режиме нужен --yes."
)
else:
if not cfg.yes:
if not confirm(f"Перезаписать содержимое {cfg.output_dir}", default=False):
typer.echo("Отмена пользователем.")
raise typer.Exit(code=1)
# Здесь должна быть реальная логика
if cfg.remote:
typer.echo(f"[info] Обработка удаленно: host={cfg.host}, source={cfg.source}")
else:
typer.echo(f"[info] Обработка локально: source={cfg.source}")
typer.echo(f"[info] Результаты: {cfg.output_dir}")
# имитируем успех
typer.echo("Готово.")
Почему этот пример полезен
- Валидация зависимостей вынесена в
build_config. - Опасность (перезапись output) учитывает как
--no-interaction, так и реальную TTY. - Ошибки единообразны по формату
[error] ...и имеют exit code2. - Команда не блокируется в CI: при отсутствии TTY и необходимости подтверждения — падает с понятным сообщением.
CI-режим: как избежать типичных ошибок и «зависаний»
При переносе CLI в CI встречаются типовые провалы:
-
Интерактивные запросы без запрета
Решение:--no-interactionи/или определение TTY. В CI по умолчанию считать non-interactive. -
Плохие exit codes
Решение: отделять ошибки валидации (например, код 2) и внутренние сбои (код 1). Для CI это критично: некоторые системы воспринимают код иначе. -
Слишком «шумные» сообщения
Traceback в CI иногда полезен, но для обычных ошибок параметров он усложняет сбор логов. Выгоднее давать краткое сообщение. -
Непредсказуемые значения по умолчанию
Например,--output-dirпо умолчанию совпадает с существующим каталогом и вызывает перезапись. Лучше:- либо делать уникальные папки по умолчанию,
- либо требовать подтверждение при существовании,
- либо сохранять результаты рядом (timestamp).
Если вы проектируете интерфейс под автоматизацию, лучше сознательно проектировать «контракт». Это тот случай, когда UX для человека и стабильность для CI должны совпасть, а не конфликтовать.
Тестирование CLI: минимальный набор гарантий
Даже небольшой проект стоит тестировать хотя бы на «контрактных» сценариях:
- обязательные аргументы отсутствуют → корректный error code,
--remoteбез--host→ ошибка,- интерактивное подтверждение в non-interactive → требование
--yes, - успешный сценарий → exit code 0.
Для Typer удобно тестировать через typer.testing.CliRunner (по сути — Click runner). Минимальный пример:
from pathlib import Path
from typer.testing import CliRunner
import tempfile
import os
from your_module import app # замените на реальное имя
runner = CliRunner()
def test_remote_requires_host():
result = runner.invoke(app, ["process", str(Path("dummy.txt")), "--remote"])
assert result.exit_code == 2
assert "[error]" in result.stdout or "[error]" in result.output
def test_non_interactive_requires_yes():
with tempfile.TemporaryDirectory() as td:
out_dir = Path(td) / "out"
out_dir.mkdir()
src = Path(td) / "in.txt"
src.write_text("data")
# имитация non-interactive сложнее, но можно проверить логику через флаг --no-interaction:
result = runner.invoke(
app,
["process", str(src), "--output-dir", str(out_dir), "--no-interaction"],
)
assert result.exit_code == 2
assert "нужен --yes" in result.output
Подводный камень: имитация TTY в unit-тестах не всегда очевидна. Поэтому флаг --no-interaction — очень хороший инструмент: он делает поведение детерминированным и удобным для тестов.
Практические советы по проектированию Typer-CLI
1) Делайте валидацию ранней и централизованной
Вынесение проверок в отдельную функцию (build_config) снижает хаос и облегчает тестирование.
2) Не злоупотребляйте количеством опций
Если опций больше 10–15 и они сильно зависят друг от друга, задумайтесь о подкомандах.
3) Придумайте «опасные действия» и управляйте ими
Удаление, перезапись, изменение критичных данных — всё это должно:
- иметь
--yes, - быть явно документировано в help,
- в CI по умолчанию требовать
--yesили падать.
4) Единый стиль ошибок — экономит время
Формат [error] ... + предсказуемый exit code — это тот минимум, который окупается быстро.
5) Help должен быть не красивым, а полезным
Лучше одна фраза с конкретикой, чем общие слова. Например:
«Перезаписывает output-dir. Требует --yes при существующем каталоге».
Typer PRO: когда хочется глубже, чем «просто работает»
Если вы уже собрали рабочий CLI по базовым принципам и хотите перейти к более продвинутым сценариям (структура команд, более аккуратные модели конфигурации, расширенные паттерны интерактивности и тестируемости), полезно отдельно пройти материал «Typer PRO» — он помогает систематизировать подход и избежать типичных архитектурных ошибок, которые обычно всплывают на второй-третьей итерации проекта. Ссылка: Typer PRO.
Вывод: предсказуемый CLI — это дизайн, а не настройка библиотеки
Typer делает создание CLI быстрым, но качество интерфейса определяется дисциплиной проектирования:
- Типы дают первичную валидацию и подсказки пользователю.
- Зависимости между опциями должны проверяться явно и рано.
- Соглашения по именам и структуре подкоманд снижают когнитивную нагрузку.
- Интерактивность обязана быть контролируемой и отключаемой в CI.
- Единый стиль ошибок и корректные exit codes помогают и человеку, и автоматизации.
Если удерживать эти принципы с первой версии, ваша CLI-утилита будет вести себя одинаково в терминале и в пайплайне: без сюрпризов, без лишнего шума, с понятными ошибками и аккуратными подсказками.
Такой подход — не «косметика интерфейса», а способ сделать инструмент надёжным продуктом: им проще пользоваться, проще поддерживать и проще доверять в автоматизированных процессах.
Комментарии
Пока нет комментариев