Пишем CLI-приложение для разработчиков на Python: команды, прогресс, exit codes и конфиги без магии
Разберём архитектуру CLI-проекта: как проектировать команды и опции, как правильно возвращать exit codes, отображать прогресс, хранить конфиги и обеспечивать повторяемость запусков. В конце — шаблон структуры репозитория под дальнейшее расширение.
Содержание
Пишем CLI-приложение для разработчиков на Python: команды, прогресс, exit codes и конфиги без магии
CLI-инструменты для разработчиков — это не «ещё один интерфейс», а часть производственного конвейера: их запускают в скриптах, CI, из Makefile, с флагами, которые живут годами. Поэтому качественная CLI-архитектура — это про предсказуемость, стабильность поведения и совместимость с автоматизацией.
В этой статье разберём, как проектировать CLI-приложение на Python «без магии»: как строить команды и опции, как корректно возвращать exit codes, как отображать прогресс, как хранить конфигурации и добиваться повторяемости запусков. В конце соберём шаблон структуры репозитория, который можно масштабировать под новые команды.
1) Базовые принципы CLI, которые спасают время
Прежде чем выбирать библиотеку (Click, Typer, argparse, Rich и т. п.), полезно договориться о правилах игры.
1.1. CLI — это контракт, а не удобство для автора
Если вы делаете mytool build --watch, то поведение должно быть:
- детерминированным (при одинаковых входах результат одинаков);
- предсказуемым при ошибках (и в человекочитаемом виде, и в машине);
- стабильным по интерфейсу (флаги и команды не исчезают без периода совместимости);
- совместимым с пайплайнами и логированием (особенно
stdout/stderr).
1.2. stdout для данных, stderr для диагностики
Общее правило:
- на
stdout— то, что потенциально можно использовать дальше: отчёты, артефакты в текстовом виде, JSON; - на
stderr— сообщения о ходе работы, предупреждения, ошибки, подсказки.
Это важно для CI: ошибки не «загрязняют» пайплайн вывода.
1.3. Exit codes — часть UX для автоматизации
Человек увидит текст ошибки. Автоматизация — увидит код завершения. Неправильные exit codes ломают сборку, усложняют диагностику и делают систему «хрупкой».
Нужно заранее определить маппинг: что означает каждый код.
2) Архитектура CLI-проекта: команды как единицы ответственности
Хороший CLI устроен вокруг логических операций, а не вокруг парсинга аргументов. Разделите три слоя:
- Интерфейс (команды/опции/валидаторы): отвечает за схему ввода.
- Сервисный слой: выполняет бизнес-операции (например, собрать проект, прогнать генерацию, выгрузить артефакты).
- Интеграции: файлы, сеть, файловая система, вызовы внешних программ, кэш, модули.
Тогда вы легко тестируете: бизнес-логику без CLI, и CLI — отдельно.
2.1. Минимальный каркас: парсер отдельно от логики
Допустим, вы проектируете команду init, которая создаёт конфиг. Правильная структура:
cli/commands.py— объявление команды и параметров;core/config.py— модель конфигурации;core/init.py— функцияrun_init(...).
Схема файлов будет позже в шаблоне репозитория.
3) Проектирование команд и опций без путаницы
Команды CLI обычно делятся на две категории:
- операции верхнего уровня (
build,test,deploy,init); - служебные операции (
doctor,version,completion).
3.1. Команды как функции: один вход — понятные параметры
Параметры команды должны быть:
- именованными (опциями), а не «позиционными всем подряд»;
- с адекватными типами;
- валидируемыми.
Например:
--configпуть к конфигу;--envимя окружения (dev,prod);--output-dirдиректория для результатов;--jobsчисло параллельных задач.
Избегайте ситуации, когда build принимает десять позиционных аргументов — это делает CLI сложным для автоматизации и опасным для людей.
3.2. Типы параметров и валидация
Даже если вы используете библиотеку, важно понимать принципы:
- Строки должны валидироваться на уровне формата (
path exists,url,semver). - Числа — ограничиваться (
--jobs >= 1). - Флаги — иметь понятный дефолт и эффект.
Практика: валидировать «на границе» (в CLI), а не в глубине логики.
3.3. Уровни сложности: конфиг + переопределения
Комфортная схема запуска разработчиков:
- есть базовая конфигурация (файл, например
tool.tomlилиconfig.yaml); - есть окружение (
--env dev); - есть аргументы CLI как override (
--jobs 8,--output-dir ...).
Это позволяет сохранять повторяемость (конфиг фиксирует поведение), но не заставляет переписывать команду целиком.
4) Exit codes: как возвращать их правильно и последовательно
Проблема большинства проектов: exit codes описаны «на словах» или возвращаются как попало. В итоге CI получает «код 1» на всё подряд, и вы теряете диагностируемость.
4.1. Пример матрицы exit codes
Пример практичной политики для разработческого CLI:
0— успех1— общая ошибка (неожиданное состояние)2— ошибки CLI (невалидные аргументы/опции)3— ошибка конфигурации (не найден, некорректный формат)4— ошибка выполнения (например, внешний инструмент вернул ошибку)5— тайм-аут6— несовместимость версий/окружения
Смысл: коду соответствует класс проблем.
4.2. Важно: отделяйте ошибки разбора и ошибки выполнения
- Если аргументы невалидны — это «ошибка интерфейса» → код
2. - Если конфиг не найден / неверный — код
3. - Если задача не выполнилась (командой вызвали внешний процесс или произошла ошибка в бизнес-логике) — код
4и так далее.
4.3. Реализация: единый механизм завершения
Даже если вы используете библиотеку для CLI, полезно иметь общий обработчик исключений, который маппит ошибки в коды.
Пример на «обычных» исключениях без привязки к конкретной библиотеке:
# core/errors.py
class CLIUsageError(Exception):
"""Неправильные аргументы или некорректные параметры команды."""
class ConfigError(Exception):
"""Конфигурация отсутствует, повреждена или противоречива."""
class ExecutionError(Exception):
"""Ошибка выполнения бизнес-задачи (внешние команды, IO, сеть)."""
class TimeoutError(Exception):
"""Операция превысила лимит времени."""
# cli/runner.py
import sys
from core.errors import (
CLIUsageError,
ConfigError,
ExecutionError,
TimeoutError,
)
EXIT_CODES = {
CLIUsageError: 2,
ConfigError: 3,
ExecutionError: 4,
TimeoutError: 5,
}
def run_with_exit_code(fn):
try:
return fn()
except Exception as e:
code = EXIT_CODES.get(type(e), 1)
# Не печатаем "трассировку" по умолчанию — зависит от флага --debug
print(str(e), file=sys.stderr)
sys.exit(code)
В реальном проекте добавляют --debug, чтобы при желании показывать traceback, но при «нормальных» запусках — держать поведение предсказуемым.
5) Прогресс: когда нужен, как сделать аккуратно и не сломать логирование
Прогресс-бары и спиннеры полезны, но легко превратить CLI в «шумный» интерфейс, который ломает пайплайны и сбор логов. Ключ: прогресс — опциональный и адаптированный к среде.
5.1. Общие правила прогресса
- Если
stdoutперенаправлен в файл/пайплайн, прогресс должен отключаться или переходить в «тихие» сообщения. - Не печатайте прогресс в
stdout, лучше использовать отдельный рендер илиstderr. - Для долгих операций полезны:
- индикатор выполнения (сколько сделано/сколько осталось),
- оценки времени,
- сообщения о текущем шаге.
5.2. Практический подход: Rich как слой визуализации
В Python популярна библиотека Rich, которая умеет рендерить прогресс и корректно работать с терминалом. Пример «скелета» с прогрессом:
# core/progress.py
from time import sleep
from rich.progress import Progress, SpinnerColumn, BarColumn, TimeElapsedColumn
def do_work_with_progress(tasks: int = 10) -> None:
with Progress(
SpinnerColumn(),
BarColumn(),
TimeElapsedColumn(),
) as progress:
task_id = progress.add_task("Processing", total=tasks)
for _ in range(tasks):
# имитируем работу
sleep(0.3)
progress.advance(task_id, 1)
Но важно: в настоящем проекте прогресс должен управляться контекстом CLI:
- включать/выключать через
--progress {auto, on, off}; - отключать при
not sys.stderr.isatty()(или аналогичной логике).
5.3. Сопоставление прогресса с логами
Если ваша команда логирует по шагам (например, в --debug), то прогресс — это только визуальный слой. Логи должны быть отдельным источником истины.
Хороший паттерн:
- прогресс показывает краткое состояние;
- детали уходят в лог (файл или
stderrв «нормальном» режиме).
6) Конфигурации без магии: источники, приоритеты и воспроизводимость
Конфиг — то место, где «магия» обычно расползается: где-то прочитали переменные окружения, где-то — параметры CLI, где-то — дефолты из кода. В итоге запуск перестаёт быть воспроизводимым.
6.1. Формат и модель конфигурации
Выберите удобный формат для команды разработчиков:
toml— часто отличный баланс для человеческого чтения и простоты;yaml— гибко, но требует осторожности и совместимости типов;json— предсказуем, но менее удобен для ручного редактирования.
Структуру конфигов лучше описывать явно: какие поля существуют, их типы, допустимые значения.
6.2. Источники конфигурации и приоритеты
Воспроизводимость достигается ясной и документированной схемой:
- базовый конфиг из файла (если указан
--config, то он главный); - конфиг по умолчанию (например,
./tool.tomlв рабочей директории) — если явный не задан; - окружение (
--env) → переключает секцию/набор параметров; - переменные окружения (
TOOL_JOBS,TOOL_TOKEN) — только как override (не как источник истины); - CLI-опции → всегда имеют наивысший приоритет.
Так вы сможете объяснить пользователю: «почему взялось это значение».
6.3. Явный merge и следы источников
Если вы хотите избежать «магии», полезно хранить в системе конфигов откуда пришло каждое значение. Это сильно облегчает дебаг.
Мини-паттерн: при сборке итоговой конфигурации формируйте структуру value + source.
Пример упрощённой модели:
# core/config.py
from dataclasses import dataclass
from typing import Optional, Dict, Any
import os
@dataclass(frozen=True)
class AppConfig:
env: str
jobs: int
output_dir: str
token: Optional[str]
def load_from_env() -> Dict[str, Any]:
jobs = os.getenv("TOOL_JOBS")
return {
"jobs": int(jobs) if jobs else None,
"token": os.getenv("TOOL_TOKEN"),
}
def merge_config(base: Dict[str, Any], overrides: Dict[str, Any]) -> Dict[str, Any]:
merged = dict(base)
for k, v in overrides.items():
if v is not None:
merged[k] = v
return merged
В реальном проекте добавляют: чтение файла, выбор секции по env, валидация, нормализация путей.
6.4. Репродуцируемость: фиксируйте окружение и артефакты
Для воспроизводимости важно не только значение конфигурации, но и привязка к контексту:
- версия инструмента (
tool version); - версия Python (в идеале — в лог/отчёт);
- git-ревизия (если это проект);
- хэши зависимостей или хотя бы lock-файл;
- таймстемпы/идентификаторы запуска (если операции зависят от внешнего мира).
Практический компромисс: при запуске сохраняйте «снимок» конфигурации и метаданные в файл результата (например, ./.tool-run/run.json).
Пример:
# core/audit.py
import json
import os
import platform
from datetime import datetime
from typing import Any, Dict
def write_run_snapshot(path: str, config: Dict[str, Any], extra: Dict[str, Any] | None = None) -> None:
payload: Dict[str, Any] = {
"timestamp": datetime.utcnow().isoformat() + "Z",
"python_version": platform.python_version(),
"config": config,
}
if extra:
payload["extra"] = extra
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
7) Как это связать в целую команду: пример мини-CLI
Ниже — цельная схема, показывающая:
- параметр
--config; - команды
generate; - прогресс;
- обработка exit codes;
- snapshot для воспроизводимости.
Чтобы пример оставался читабельным, покажем логику «в связке» без жёсткой привязки к конкретной библиотеке. Но по структуре это переносится на Typer/Click/argparse.
# cli/main.py
import sys
import json
from pathlib import Path
from dataclasses import asdict, is_dataclass
from typing import Optional, Dict, Any
from core.errors import CLIUsageError, ConfigError, ExecutionError, TimeoutError
from core.progress import do_work_with_progress
from core.audit import write_run_snapshot
def parse_args(argv) -> Dict[str, Any]:
# В реальном проекте используйте argparse/Typer.
# Здесь — упрощённый псевдо-парсер, чтобы показать архитектуру.
if not argv:
raise CLIUsageError("Command is required: generate")
cmd = argv[0]
if cmd != "generate":
raise CLIUsageError(f"Unknown command: {cmd}")
config_path: Optional[str] = None
env: str = "dev"
jobs: int = 4
output_dir: str = "./out"
progress: str = "auto"
i = 1
while i < len(argv):
if argv[i] == "--config":
i += 1
config_path = argv[i]
elif argv[i] == "--env":
i += 1
env = argv[i]
elif argv[i] == "--jobs":
i += 1
jobs = int(argv[i])
elif argv[i] == "--output-dir":
i += 1
output_dir = argv[i]
elif argv[i] == "--progress":
i += 1
progress = argv[i]
else:
raise CLIUsageError(f"Unknown option: {argv[i]}")
i += 1
if jobs < 1:
raise CLIUsageError("--jobs must be >= 1")
return {
"cmd": cmd,
"config_path": config_path,
"env": env,
"jobs": jobs,
"output_dir": output_dir,
"progress": progress,
}
def load_config_file(path: str) -> Dict[str, Any]:
p = Path(path)
if not p.exists():
raise ConfigError(f"Config file not found: {path}")
# Пример: читаем json, чтобы не усложнять формат в статье.
# В реальном проекте чаще используют TOML/YAML.
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception as e:
raise ConfigError(f"Invalid config: {e}") from e
def main(argv: list[str]) -> int:
EXIT_CODES = {
CLIUsageError: 2,
ConfigError: 3,
ExecutionError: 4,
TimeoutError: 5,
}
try:
args = parse_args(argv)
config_data: Dict[str, Any] = {}
if args["config_path"]:
config_data = load_config_file(args["config_path"])
# merge config: CLI override всегда сильнее
merged = dict(config_data)
merged.update({
"env": args["env"],
"jobs": args["jobs"],
"output_dir": args["output_dir"],
})
# Snapshot воспроизводимости
run_snapshot_path = str(Path(merged["output_dir"]) / ".tool-run" / "run.json")
write_run_snapshot(run_snapshot_path, merged, extra={"command": args["cmd"]})
# Прогресс: в реальном CLI решайте "auto/on/off"
# Здесь просто демонстрируем сам принцип.
do_work_with_progress(tasks=merged["jobs"])
return 0
except Exception as e:
code = EXIT_CODES.get(type(e), 1)
print(str(e), file=sys.stderr)
return code
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
Этот пример намеренно упрощён, но показывает важную идею: CLI — оболочка, а не место, где всё перемешано. В реальном проекте парсинг аргументов лучше доверить библиотеке, но схема merge -> snapshot -> run -> exit code остаётся.
8) Typer/Click/argparse: что выбрать и как не ошибиться
Python-библиотек для CLI много, и выбор зависит от требований:
- argparse: стандартная библиотека, много кода руками, но предсказуемо;
- Click: зрелая экосистема, удобный API;
- Typer: строится поверх Click и даёт удобство типизации/документации на основе аннотаций.
Если вы ориентируетесь на «команды, прогресс и конфиги без магии», выбирайте инструмент, который:
- явно описывает параметры и помогает валидации;
- не скрывает поведение (как устроены опции/команды);
- позволяет нормально интегрироваться с логированием и обработкой ошибок.
Тут стоит упомянуть: чтобы разбираться в нюансах Typer глубже (аннотации, схемы команд, обработка параметров), полезно посмотреть материалы из Typer PRO — как способ структурировать знания, а не как «готовое решение».
9) Типичные ошибки в CLI (и как их избежать)
9.1. «exit code всегда 1»
Почти все начинающие проекты со временем упираются в то, что «ничего не работает — вернём 1». Потом приходится отлаживать, потому что вы не знаете класс ошибки.
Решение: задокументируйте коды и маппите их на категории.
9.2. Смешивание stdout и stderr
Когда прогресс/логи пишутся в stdout, пайплайны становятся непредсказуемыми. В лучшем случае — ломается парсинг JSON, в худшем — вы получаете «мусор» в артефактах.
Решение: прогресс и логи — в stderr, данные — в stdout.
9.3. Неочевидный merge конфигурации
Когда значения «то из файла, то из окружения, то из дефолтов» без приоритета — вы теряете воспроизводимость.
Решение: фиксируйте приоритет и, по возможности, сохраняйте snapshot.
9.4. Конфиги меняют поведение без версии
Если вы обновили схему конфига или алгоритм, а старые конфиги продолжают «как будто работать», вы получаете тихую деградацию.
Решение: добавляйте версию конфигурации, делайте миграции или хотя бы фиксируйте совместимость.
9.5. Прогресс «всегда включён»
В CI и при перенаправлении потоков прогресс превращается в набор управляющих последовательностей, которые сложно разобрать.
Решение: auto/on/off и проверка TTY.
10) Шаблон структуры репозитория под расширение
Один из лучших индикаторов качества CLI-проекта — как легко вы добавляете новую команду без копипасты и без «всё в main.py».
Ниже — структура, которая обычно хорошо масштабируется.
mytool/
pyproject.toml
README.md
LICENSE
src/
mytool/
__init__.py
cli/
__init__.py
main.py # точка входа
commands.py # регистрация команд (если нужно)
options.py # общие типы/валидация опций (по желанию)
runner.py # обработка ошибок -> exit codes
core/
__init__.py
config.py # модели и загрузка/merge конфигов
audit.py # snapshot запуска
progress.py # утилиты для прогресса
generate.py # бизнес-логика: generate
build.py # бизнес-логика: build (позже)
integrations/
__init__.py
fs.py # операции с файловой системой
subprocess.py # обёртки вызова внешних команд
net.py # сеть (если понадобится)
tests/
test_config.py
test_generate.py
test_cli_exit_codes.py
docs/
cli.md # документация по командам
config.md # формат конфигов и приоритеты
Почему это работает
coreсодержит бизнес-логику и почти не зависит от CLI.cliтонкий: парсинг + вызовcore+ маппинг ошибок/exit codes.integrationsизолирует внешние зависимости (файлы/процессы/сеть) — это облегчает тестирование.testsпроверяют конфиг и exit code поведение отдельно, не привязываясь к реальному терминалу.
Вывод: CLI как инженерный продукт, а не набор скриптов
Хорошее CLI-приложение для разработчиков — это дисциплина: чёткая архитектура слоёв, понятные команды и опции, стабильные exit codes, прогресс с уважением к логам и воспроизводимость через конфигурацию и snapshot.
Если вы только начинаете или «дорастаете» до расширения инструмента, держите список контрольных вопросов:
- Есть ли у каждой команды понятный контракт входных параметров?
- Возвращаем ли мы корректные exit codes по классам ошибок?
- Не загрязняем ли
stdoutпрогрессом и диагностикой? - Ясна ли схема приоритетов конфигураций и override?
- Сохраняем ли мы снимок запуска для воспроизводимости?
- Легко ли добавить новую команду без переписывания всего?
Практически это означает: вы проектируете CLI так же строго, как и код библиотеки. А чтобы глубже разобраться в тонкостях удобной разработки на Typer (особенно в части организации команд и параметров), можно дополнить практику материалами вроде Typer PRO — в том объёме, который действительно нужен под ваш текущий проект.
Комментарии
Пока нет комментариев