$ sudo teach|
    $ sudo teach|
    IT school
  • Telegram
  • Партнёрам
  • Все курсы
$ sudo teach IT
OOO "SALEPROFIT"Контакты и реквизитыIT-Park Logo

Школа

  • Блог
  • Проверить сертификат

Сотрудничество

  • Стать учителем
  • Партнёрская программа
  • О проекте

Право

  • Оферта
  • Политика конфиденциальности

© 2023–2026 $ sudo teach IT™. All Rights Reserved. Public user contributions licensed under CC BY-SA 4.0 license with attribution required
TelegramGitHubYouTube
ГлавнаяБлогCLI-утилиты для разработчиков на Python с Typer: интерактивные сценарии, подтверждения и сохранение конфигов

CLI-утилиты для разработчиков на Python с Typer: интерактивные сценарии, подтверждения и сохранение конфигов

$ sudo teach IT
·15 августа 2026 г.·15 мин·33
CLI-утилиты для разработчиков на Python с Typer: интерактивные сценарии, подтверждения и сохранение конфигов

Соберём пример CLI-проекта с Typer: интерактивные вопросы, подтверждения опасных действий, автозагрузка конфигов из файлов, валидация параметров и удобные сообщения об ошибках. Доведём до вида, пригодного для команды и CI.

Содержание
Постановка задачи: что должна уметь утилитаАрхитектура проекта: “правильные” слои для CLIПодготовка зависимостей и конфигурацииПример pyproject.tomlМодель конфигурации: типы как контрактМодель: src/appctl/models.pyРабота с конфигами: чтение, запись, применение обновленийIO-утилиты: src/appctl/ioutils.pyДоменный слой: конфиг как объект, а не строкаsrc/appctl/config.pyUX: интерактивные вопросы и подтвержденияsrc/appctl/ux.pyCLI слой: Typer-команды с правильной обработкой ошибокБазовая структура: src/appctl/cli.pyКоманда init: интерактивные сценарии + сохранение конфигаРеализация init: src/appctl/cli.pyКоманда show: чтение и понятный выводКоманда set: частичное обновление с валидациейРеализация setКоманда deploy: подтверждения опасного действия и dry-runРеализация deployКоманда clean: удаление конфигурации с обязательным подтверждениемУдобные сообщения об ошибках: что делать, когда всё пошло не такТестирование логики конфиговtests/testconfig.pyПриведение к виду “для команды и CI”1) Единый формат параметров и выходных кодов2) Меньше “магии”, больше предсказуемых путей3) Настройка линтеров/форматтеров4) Безопасность интерактивности в CI5) Документация через --helpКак “дожать” пример до уровня productionИтог: почему Typer удобен именно для “живых” утилит

Python давно перестал быть “языком для скриптов”. В реальной команде он закрывает и инфраструктурные задачи, и разработку внутренних тулов. Но если утилита не продумана как продукт для людей (а не просто для вас), она быстро превращается в источник ошибок: непредсказуемые аргументы, отсутствие интерактивности, “опасные” команды без подтверждений, сломанные конфиги и непонятные сообщения об ошибках.

Typer — один из самых практичных способов сделать CLI на Python: он опирается на типы (type hints), сам генерирует help, поддерживает интерактивные сценарии и хорошо ложится на современный стек (Pydantic для схем, Rich для UX, единый формат конфигов и валидации). В этой статье мы соберём небольшой, но “боевой” CLI-проект: интерактивные вопросы, подтверждения опасных действий, автозагрузка конфигов из файлов, валидация параметров и удобные сообщения об ошибках. Доведём код до состояния, пригодного для команды и для CI.


Постановка задачи: что должна уметь утилита

Сценарий будет приближен к реальности: есть конфигурация сервиса, которую мы должны уметь:

  1. Загружать из файлов (например, YAML/JSON), плюс поддерживать переопределения через аргументы CLI.
  2. Собирать интерактивные ответы при запуске без некоторых параметров.
  3. Запрашивать подтверждение для опасных действий (например, “удалить конфиг”, “перезаписать существующий файл”, “выполнить миграцию”).
  4. Валидировать входные параметры до того, как они попадут в файловую систему.
  5. Покрывать понятными ошибками: что именно неверно и как исправить.
  6. Быть пригодной для команды и CI: лаконичная структура, предсказуемые зависимости, тестируемость и единый формат сообщения об ошибках.

Чтобы не раздувать статью, мы сделаем утилиту appctl с командами:

  • init: создать конфиг по шаблону (с интерактивными вопросами).
  • set: обновить значения в конфиге (с валидацией).
  • deploy: имитировать “опасное” действие, требующее подтверждения (и опционально работать в “безопасном” режиме dry-run).
  • show: показать текущий конфиг.
  • clean: удалить конфиг (тут подтверждение обязательно).

Архитектура проекта: “правильные” слои для CLI

Даже небольшой CLI стоит разделить на слои, иначе он начинает “жить” в одном main.py, а потом вам приходится отлаживать хаос. В практическом подходе удобно выделить:

  • CLI слой: команды Typer, парсинг аргументов, сообщения пользователю.
  • Схема/валидация: Pydantic модели.
  • Доменный слой: чтение/запись конфигов, применение патчей.
  • UX слой: интерактивные вопросы, подтверждения, форматирование ошибок.

Структура, к которой мы придём:

code
appctl/
  pyproject.toml
  src/appctl/
    __init__.py
    cli.py
    config.py
    models.py
    io_utils.py
    ux.py
  tests/
    test_config.py
  README.md

Подготовка зависимостей и конфигурации

Typer сам по себе справится с CLI, но для “нормального” UX и валидации нам пригодятся:

  • typer
  • pydantic (или pydantic v2)
  • rich (для красивых сообщений и удобного форматирования)
  • pyyaml (если будем поддерживать YAML)
  • typing-extensions (не обязательно, зависит от версий)
  • тестовый стек: pytest

Пример pyproject.toml

code
[project]
name = "appctl"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
  "typer>=0.9",
  "pydantic>=2.0",
  "rich>=13.0",
  "pyyaml>=6.0"
]

[project.scripts]
appctl = "appctl.cli:app"

[tool.pytest.ini_options]
testpaths = ["tests"]

Модель конфигурации: типы как контракт

Главная сила Typer в том, что типы из Pydantic/аннотаций превращаются в ожидаемые параметры CLI. Но Typer не “валидирует всё само по себе” — он помогает, а реальную схему лучше держать в Pydantic.

Модель: src/appctl/models.py

code
from __future__ import annotations

from typing import Literal
from pydantic import BaseModel, Field, ConfigDict


class AppConfig(BaseModel):
    env: Literal["dev", "staging", "prod"] = Field(
        default="dev",
        description="Среда выполнения",
    )
    host: str = Field(
        default="localhost",
        min_length=1,
        description="Хост сервиса",
    )
    port: int = Field(
        default=8080,
        ge=1,
        le=65535,
        description="Порт сервиса",
    )
    workers: int = Field(
        default=4,
        ge=1,
        le=128,
        description="Число воркеров",
    )
    feature_flags: list[str] = Field(
        default_factory=list,
        description="Список включенных фич",
    )

    model_config = ConfigDict(extra="forbid")

extra="forbid" — это важная настройка для корпоративных тулов. Если в конфиг попало лишнее поле (из-за опечатки/устаревшей версии), лучше упасть с понятной ошибкой, чем “тихо игнорировать” и потом искать причину.


Работа с конфигами: чтение, запись, применение обновлений

Нам нужно уметь:

  • найти конфиг (по умолчанию, например .appctl/config.yaml или .json);
  • прочитать и распарсить файл;
  • применить изменения (патч) и сохранить;
  • корректно обработать несуществующие файлы.

IO-утилиты: src/appctl/io_utils.py

code
from __future__ import annotations

from pathlib import Path
from typing import Any
import json
import yaml


def load_data(path: Path) -> dict[str, Any]:
    if not path.exists():
        raise FileNotFoundError(f"Файл не найден: {path}")

    suffix = path.suffix.lower()
    raw = path.read_text(encoding="utf-8")

    if suffix in [".yaml", ".yml"]:
        data = yaml.safe_load(raw) or {}
    elif suffix == ".json":
        data = json.loads(raw or "{}")
    else:
        raise ValueError(f"Неподдерживаемый формат конфига: {suffix}")

    if not isinstance(data, dict):
        raise ValueError("Конфиг должен быть JSON/YAML-объектом (маппингом).")

    return data


def dump_data(path: Path, data: dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)

    suffix = path.suffix.lower()
    if suffix in [".yaml", ".yml"]:
        path.write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True), encoding="utf-8")
    elif suffix == ".json":
        path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
    else:
        raise ValueError(f"Неподдерживаемый формат конфига: {suffix}")

Доменный слой: конфиг как объект, а не строка

src/appctl/config.py

code
from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Optional

from pydantic import ValidationError

from .io_utils import load_data, dump_data
from .models import AppConfig


DEFAULT_CONFIG_PATH = Path(".appctl") / "config.yaml"


@dataclass(frozen=True)
class ConfigStore:
    path: Path = DEFAULT_CONFIG_PATH

    def exists(self) -> bool:
        return self.path.exists()

    def load(self) -> AppConfig:
        data = load_data(self.path)
        return AppConfig.model_validate(data)

    def save(self, config: AppConfig) -> None:
        dump_data(self.path, config.model_dump())

    def load_or_default(self, default: AppConfig) -> AppConfig:
        if self.exists():
            return self.load()
        return default

    def merge_patch(self, base: AppConfig, patch: dict) -> AppConfig:
        # model_copy(update=...) в pydantic v2 корректно делает частичное обновление
        return base.model_copy(update=patch)

    def validate_patch(self, patch: dict) -> dict:
        """
        Прогоняем патч через Pydantic, чтобы ловить очевидные ошибки
        (тип/диапазоны). Для этого используем AppConfig и проверяем full model?
        Поскольку патч частичный, делаем более практичную схему:
        валидируем отдельные поля через созданную модель с базовыми значениями.
        """
        # Этот метод будет использован совместно с base-объектом, поэтому здесь оставим простую заглушку.
        # На практике можно построить Patch-model.
        return patch

В реальном проекте стоит сделать отдельные “patch” модели (чтобы валидировать только изменяемые поля). В рамках статьи, чтобы не расширять код, мы будем валидировать итоговую конфигурацию после применения патча.


UX: интерактивные вопросы и подтверждения

В CLI важно не просто “спросить”, а спросить правильно:

  • тип вопроса должен соответствовать типу поля;
  • при вводе пользователь может нажать Enter, чтобы оставить значение;
  • опасные действия должны иметь y/N подтверждение;
  • при отказе — корректный выход с ненулевым кодом или нулём (зависит от политики команды; обычно отказ — нулевой код, чтобы не ломать пайплайны).

src/appctl/ux.py

code
from __future__ import annotations

from typing import Optional
from rich.console import Console

console = Console()

def confirm(question: str, default: bool = False) -> bool:
    suffix = " [Y/n]" if default else " [y/N]"
    while True:
        answer = console.input(f"{question}{suffix}: ").strip().lower()
        if not answer:
            return default
        if answer in ("y", "yes"):
            return True
        if answer in ("n", "no"):
            return False
        console.print("Пожалуйста, ответьте 'y' или 'n'.")


def ask_text(question: str, default: Optional[str] = None) -> Optional[str]:
    prompt = f"{question}"
    if default is not None:
        prompt += f" [{default}]"
    prompt += ": "
    answer = console.input(prompt).strip()
    if not answer and default is not None:
        return default
    return answer or None


def ask_int(question: str, default: Optional[int] = None) -> Optional[int]:
    while True:
        raw = ask_text(question, str(default) if default is not None else None)
        if raw is None or raw == "":
            return default
        try:
            return int(raw)
        except ValueError:
            console.print("Введите целое число.")


def ask_list(question: str, default: Optional[list[str]] = None) -> list[str]:
    """
    Ввод: через запятую. Пустой ввод вернет default.
    """
    default_str = ", ".join(default) if default else ""
    prompt = f"{question}"
    if default:
        prompt += f" [{default_str}]"
    prompt += ": "
    answer = console.input(prompt).strip()
    if not answer and default is not None:
        return default
    if not answer:
        return []
    items = [x.strip() for x in answer.split(",")]
    return [x for x in items if x]

CLI слой: Typer-команды с правильной обработкой ошибок

Базовая структура: src/appctl/cli.py

Здесь мы покажем подход, который будет удобен для команды:

  • единый обработчик ошибок в стиле “покажи пользователю, что не так”;
  • общий --config параметр на команды;
  • поддержка режима --yes для пропуска интерактивного подтверждения в CI.
code
from __future__ import annotations

from pathlib import Path
from typing import Optional

import typer
from pydantic import ValidationError
from rich.console import Console

from .config import ConfigStore, DEFAULT_CONFIG_PATH
from .models import AppConfig
from .ux import confirm, ask_int, ask_list, ask_text

app = typer.Typer(add_completion=False)
console = Console()


def common_config_path(config: Optional[Path]) -> Path:
    return config or DEFAULT_CONFIG_PATH


def handle_validation_error(e: ValidationError) -> None:
    # Pydantic v2: errors() дает структурированные данные.
    console.print("[bold red]Ошибка валидации конфигурации:[/bold red]")
    for err in e.errors():
        loc = ".".join(str(x) for x in err.get("loc", []))
        msg = err.get("msg")
        typ = err.get("type")
        console.print(f" - {loc}: {msg} ({typ})")
    raise typer.Exit(code=2)


@app.callback()
def main() -> None:
    # Можно здесь настроить глобальный контекст/логирование.
    pass

Команда init: интерактивные сценарии + сохранение конфига

init должна:

  • либо создать конфиг из аргументов,
  • либо спросить недостающие значения,
  • либо создать файл, если его нет; при наличии — требовать подтверждение.

Пример командных параметров:

  • --config PATH — куда сохранять;
  • --env, --host, --port, --workers — опциональные параметры;
  • --feature-flag — повторяемый параметр --feature-flag x;
  • --force — перезаписать без вопросов (аккуратно: в CI это удобно);
  • --yes — пропуск подтверждения опасных действий (как общее правило).

Реализация init: src/appctl/cli.py

code
@app.command()
def init(
    config: Optional[Path] = typer.Option(None, "--config", "-c", help="Путь к конфигу (yaml/json)."),
    env: Optional[str] = typer.Option(None, help="Среда: dev|staging|prod."),
    host: Optional[str] = typer.Option(None, help="Хост сервиса."),
    port: Optional[int] = typer.Option(None, help="Порт сервиса."),
    workers: Optional[int] = typer.Option(None, help="Число воркеров."),
    feature_flag: Optional[list[str]] = typer.Option(
        None, "--feature-flag", help="Флаг-фичи. Можно указать несколько раз."
    ),
    force: bool = typer.Option(False, "--force", help="Перезаписать существующий конфиг без подтверждения."),
    yes: bool = typer.Option(False, "--yes", help="Принять подтверждения по умолчанию для опасных действий."),
) -> None:
    """
    Создает конфиг. Если часть параметров не задана — спросит интерактивно.
    """
    path = common_config_path(config)
    store = ConfigStore(path)

    default = AppConfig()

    if store.exists() and not (force or yes):
        if not confirm(f"Конфиг уже существует: {path}. Перезаписать?", default=False):
            console.print("Отменено пользователем.")
            raise typer.Exit(code=0)

    final_env = env
    final_host = host
    final_port = port
    final_workers = workers
    final_flags = feature_flag

    if final_env is None:
        # Typer сам ограничит по Literal, но в интерактиве мы сделаем проще:
        final_env = ask_text("Среда env (dev/staging/prod)", default=default.env) or default.env

    if final_host is None:
        final_host = ask_text("Хост host", default=default.host) or default.host

    if final_port is None:
        final_port = ask_int("Порт port", default=default.port) or default.port

    if final_workers is None:
        final_workers = ask_int("Workers", default=default.workers) or default.workers

    if final_flags is None:
        final_flags = ask_list("Feature flags (через запятую)", default=default.feature_flags)

    # Валидируем итог.
    try:
        cfg = AppConfig(
            env=final_env,
            host=final_host,
            port=final_port,
            workers=final_workers,
            feature_flags=final_flags,
        )
    except ValidationError as e:
        handle_validation_error(e)

    store.save(cfg)
    console.print(f"[green]Готово:[/green] конфиг сохранен в {path}")

Нюанс: мы валидируем только итоговую модель. Это проще для частичных сценариев и снижает риск “валидируем патч неправильно”. В более зрелой версии можно сделать отдельные patch-модели для точечной диагностики.


Команда show: чтение и понятный вывод

show — пример “неопасной” команды: она должна либо вывести текстом, либо красиво отформатировать JSON/YAML.

code
import json

from rich.syntax import Syntax

@app.command()
def show(
    config: Optional[Path] = typer.Option(None, "--config", "-c", help="Путь к конфигу.")
) -> None:
    """Показывает текущий конфиг."""
    path = common_config_path(config)
    store = ConfigStore(path)
    if not store.exists():
        console.print(f"[red]Конфиг не найден:[/red] {path}")
        raise typer.Exit(code=1)

    try:
        cfg = store.load()
    except (FileNotFoundError, ValueError) as e:
        console.print(f"[red]Ошибка чтения конфига:[/red] {e}")
        raise typer.Exit(code=1)

    payload = json.dumps(cfg.model_dump(), ensure_ascii=False, indent=2)
    console.print(Syntax(payload, "json", theme="monokai", line_numbers=False))

Команда set: частичное обновление с валидацией

Команда set часто требуется разработчикам, потому что не всегда нужно заново запускать init. Мы сделаем частичное обновление:

  • пользователь указывает только нужные поля;
  • если какой-то аргумент не передан — значение остаётся прежним;
  • если конфига нет — команда должна предложить создать его (или падать; в статье реализуем вариант с подсказкой).

Реализация set

code
@app.command()
def set(
    config: Optional[Path] = typer.Option(None, "--config", "-c", help="Путь к конфигу."),
    env: Optional[str] = typer.Option(None, help="Среда dev|staging|prod."),
    host: Optional[str] = typer.Option(None, help="Хост."),
    port: Optional[int] = typer.Option(None, help="Порт."),
    workers: Optional[int] = typer.Option(None, help="Количество воркеров."),
    feature_flag: Optional[list[str]] = typer.Option(
        None, "--feature-flag", help="Полностью заменить feature flags на список."
    ),
    interactive: bool = typer.Option(False, "--interactive", "-i", help="Спросить недостающие значения интерактивно."),
) -> None:
    """
    Обновляет поля в конфиге. Валидирует итоговую конфигурацию.
    """
    path = common_config_path(config)
    store = ConfigStore(path)

    if not store.exists():
        console.print(f"[red]Конфиг не найден:[/red] {path}. Сначала выполните [bold]appctl init[/bold].")
        raise typer.Exit(code=1)

    try:
        base = store.load()
    except ValidationError as e:
        handle_validation_error(e)
    except Exception as e:
        console.print(f"[red]Ошибка загрузки:[/red] {e}")
        raise typer.Exit(code=1)

    patch: dict = {}

    if env is not None:
        patch["env"] = env
    if host is not None:
        patch["host"] = host
    if port is not None:
        patch["port"] = port
    if workers is not None:
        patch["workers"] = workers
    if feature_flag is not None:
        patch["feature_flags"] = feature_flag

    if interactive:
        # Спрашиваем только те поля, которые не были заданы аргументами.
        if "env" not in patch:
            patch["env"] = ask_text("env", default=base.env) or base.env
        if "host" not in patch:
            patch["host"] = ask_text("host", default=base.host) or base.host
        if "port" not in patch:
            patch["port"] = ask_int("port", default=base.port) or base.port
        if "workers" not in patch:
            patch["workers"] = ask_int("workers", default=base.workers) or base.workers
        if "feature_flags" not in patch:
            patch["feature_flags"] = ask_list("feature_flags", default=base.feature_flags)

    new_cfg = base.model_copy(update=patch)

    try:
        # model_copy уже скопировал поля, но нам нужна валидация итоговой модели.
        # В pydantic v2 модель обычно валидируется при создании; тут проще вызвать model_validate:
        cfg_validated = AppConfig.model_validate(new_cfg.model_dump())
    except ValidationError as e:
        handle_validation_error(e)

    store.save(cfg_validated)
    console.print("[green]Готово:[/green] конфиг обновлен.")

Команда deploy: подтверждения опасного действия и dry-run

Здесь появляется типичная политика: в командах CI/локально полезны “dry-run” и “без подтверждений”. Но подтверждение всё равно нужно по умолчанию при опасном действии (например, когда нельзя откатить).

Параметры:

  • --dry-run: ничего не меняем, просто показываем план;
  • --yes: подтверждаем без интерактива;
  • --config: откуда взять параметры.

Реализация deploy

code
@app.command()
def deploy(
    config: Optional[Path] = typer.Option(None, "--config", "-c", help="Путь к конфигу."),
    dry_run: bool = typer.Option(False, "--dry-run", help="Не выполняет изменения, только показывает план."),
    yes: bool = typer.Option(False, "--yes", help="Пропустить подтверждение опасной операции."),
) -> None:
    """
    Условная команда развертывания. Опасная: требует подтверждения по умолчанию.
    """
    path = common_config_path(config)
    store = ConfigStore(path)

    if not store.exists():
        console.print(f"[red]Конфиг не найден:[/red] {path}")
        raise typer.Exit(code=1)

    try:
        cfg = store.load()
    except ValidationError as e:
        handle_validation_error(e)
    except Exception as e:
        console.print(f"[red]Ошибка чтения:[/red] {e}")
        raise typer.Exit(code=1)

    plan = {
        "env": cfg.env,
        "host": cfg.host,
        "port": cfg.port,
        "workers": cfg.workers,
        "feature_flags": cfg.feature_flags,
    }

    console.print("[bold]План развертывания:[/bold]")
    console.print(plan)

    if dry_run:
        console.print("[yellow]Dry-run:[/yellow] выполнение не производится.")
        raise typer.Exit(code=0)

    # Опасное действие: запрашиваем подтверждение.
    if not yes:
        if not confirm("Продолжить развертывание?", default=False):
            console.print("Отменено пользователем.")
            raise typer.Exit(code=0)

    # Здесь бы был реальный вызов: запуск миграций, деплой в окружение и т.п.
    console.print("[green]Развертывание выполнено (демо).[/green]")

Команда clean: удаление конфигурации с обязательным подтверждением

С точки зрения UX это must-have: “delete” без подтверждения — почти всегда ошибка. В нашем примере удаление всегда спрашивает подтверждение, а --yes позволяет пропустить.

code
@app.command()
def clean(
    config: Optional[Path] = typer.Option(None, "--config", "-c", help="Путь к конфигу."),
    yes: bool = typer.Option(False, "--yes", help="Пропустить подтверждение."),
) -> None:
    """Удаляет конфиг. Опасное действие — подтверждение обязательно."""
    path = common_config_path(config)
    store = ConfigStore(path)

    if not store.exists():
        console.print(f"[yellow]Нечего удалять:[/yellow] конфиг не существует: {path}")
        raise typer.Exit(code=0)

    if not yes:
        if not confirm(f"Удалить конфиг {path}?", default=False):
            console.print("Отменено пользователем.")
            raise typer.Exit(code=0)

    path.unlink()
    console.print(f"[green]Удалено:[/green] {path}")

Удобные сообщения об ошибках: что делать, когда всё пошло не так

Ошибки в CLI — это не только “красный текст”. Это:

  • корректный exit code (для CI);
  • локализация проблемы (поле, диапазон, ожидаемый формат);
  • отсутствие трассировок в обычном режиме.

В нашем коде обработка ValidationError централизована через handle_validation_error. Это ключ к читаемому UX: вы не ловите ошибки в каждом месте и не дублируете логику.

Один более тонкий момент: pydantic возвращает loc, который мы объединяем в “a.b.c”. Это хорошо работает, если поля вложенные. Для плоской модели — достаточно.


Тестирование логики конфигов

CLI-тесты по кнопкам и интерактиву сложнее. Но “сердце” утилиты (загрузка/сохранение/валидация) можно тестировать отдельно от Typer.

tests/test_config.py

code
from pathlib import Path
from pydantic import ValidationError

from appctl.config import ConfigStore
from appctl.models import AppConfig


def test_save_and_load(tmp_path: Path):
    p = tmp_path / "config.yaml"
    store = ConfigStore(p)

    cfg = AppConfig(env="dev", host="127.0.0.1", port=8000, workers=2, feature_flags=["a"])
    store.save(cfg)

    loaded = store.load()
    assert loaded == cfg


def test_invalid_port(tmp_path: Path):
    p = tmp_path / "config.yaml"
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text("env: dev\nhost: localhost\nport: 99999\nworkers: 1\nfeature_flags: []\n", encoding="utf-8")

    store = ConfigStore(p)
    try:
        store.load()
        assert False, "Ожидали исключение"
    except ValidationError as e:
        # порт должен быть <= 65535
        assert any(err["loc"] == ("port",) for err in e.errors())

Эти тесты дают уверенность, что валидация работает, и чтение/запись не разрушает данные.


Приведение к виду “для команды и CI”

Вот список практических требований, которые стоит закрыть до того, как утилита попадёт в общий репозиторий.

1) Единый формат параметров и выходных кодов

  • Все опасные команды имеют подтверждение по умолчанию и --yes для автоматизации.
  • Валидационные ошибки дают exit code 2, “файл не найден” — 1.

В коде Typer typer.Exit(code=...) управляет exit status.

2) Меньше “магии”, больше предсказуемых путей

Мы используем DEFAULT_CONFIG_PATH = .appctl/config.yaml. Это стоит задокументировать в README и по возможности позволить переопределять через --config.

3) Настройка линтеров/форматтеров

Добавьте в проект ruff и black (в реальном репозитории это стандарт). В статье код укладывается в “разумный” стиль, но команда всё равно будет требовать форматирование.

4) Безопасность интерактивности в CI

В CI stdin обычно закрыт или неинтерактивен. Поэтому в документации (и иногда в коде) важно явно указывать: “Для опасных действий используйте --yes”.

Можно усилить: если sys.stdin.isatty() false и --yes не задан — падать с понятной ошибкой. В статье мы не добавляли это ради компактности, но это хороший шаг.

5) Документация через --help

Typer автоматически генерирует help из docstring и параметров. Это тоже часть “командной пригодности”: разработчик должен понять команду без чтения исходников.


Как “дожать” пример до уровня production

В текущем варианте это уже полезный шаблон, но в production-проектах обычно добавляют:

  1. Разделение “патчевых” моделей: AppConfigPatch для set, чтобы валидация была более точной и сообщения об ошибках не смешивались.
  2. Поддержка конфигурации из нескольких источников:
    • файл конфигурации;
    • переменные окружения;
    • аргументы CLI с приоритетами.
  3. Версионирование конфигурации: поле schema_version и миграции при загрузке.
  4. Логи вместо print: интеграция logging для CI.
  5. Проверки наличия директории и “атомарная запись”: сохранять во временный файл и делать rename, чтобы не оставить поврежденный конфиг при сбое.

Если вы уже начинаете думать о таких улучшениях, это правильная траектория: CLI — инструмент, который живёт долго.


Итог: почему Typer удобен именно для “живых” утилит

Мы собрали небольшой CLI на Python с Typer, но главное — мы сделали то, что отличает “скрипт” от утилиты для команды:

  • интерактивные вопросы там, где это экономит время и снижает ошибки;
  • подтверждения опасных действий по умолчанию;
  • чтение/сохранение конфигов в файловом формате с валидацией;
  • централизованная и понятная обработка ошибок;
  • предсказуемое поведение в CI через --yes и --dry-run.

Если вы хотите углубиться в практики Typer (например, расширенные паттерны группировки команд, зависимостей, интерактивных сценариев, типизации и архитектуры CLI-проектов), полезным следующим шагом станет курс Typer PRO — как способ систематизировать знания и быстрее перейти от “работает у меня” к утилитам уровня команды.


Войдите, чтобы поставить лайк и оставить комментарий.

Автор

$ sudo teach IT

Typer PRO

Курс по теме

Typer PRO

С нуля до уверенной работы с CLI-проектами на Python. Подходит для начинающих и тех, кто уже пишет на Python. Разберитесь с Typer раз и навсегда — и перестаньте копировать команды вслепую.

Открыть курс

Продолжите обучение

Все курсы
Python – для начинающих!

Python – для начинающих!

С нуля до профессионального уровня. Подходит для всех. Учитесь каждый день и овладейте самым популярным языком программирования.

Перейти к курсу

Приложения для iPhone и Apple Watch на SwiftUI

Разработка приложений для iPhone и Apple Watch на SwiftUI: навигация, SwiftData, виджеты, часы, выпуск. Нужен Mac с Xcode 27, сами устройства не нужны.

Перейти к курсу
Ботостроение Telegram

Ботостроение Telegram

Лёгкий, быстрый и доступный способ познакомиться с миром ботостроения в Telegram. Видео, конспекты, практика и помощь – всё у нас на курсе.

Перейти к курсу

Приложения для macOS на SwiftUI

Разработка приложений для Mac на SwiftUI: окна и меню, Liquid Glass, SwiftData, сеть, выпуск. Нужен Mac с macOS 27 и Xcode 27.

Перейти к курсу

Другие статьи

Как устроен сборщик мусора в CPython: трассировка объектов и циклические ссылки
устроен

Как устроен сборщик мусора в CPython: трассировка объектов и циклические ссылки

Погружаемся в механизм reference counting и cyclic garbage collector — смотрим на исходный код CPython, пишем тесты и выясняем, когда объект действительно удаляется из памяти.

17 июля 2026 г.
620
SQLModel vs SQLAlchemy: что выбрать для Python-проекта с FastAPI
sqlmodel

SQLModel vs SQLAlchemy: что выбрать для Python-проекта с FastAPI

Сравниваем два подхода к работе с базами данных в Python-экосистеме: где SQLModel упрощает жизнь, а где мощность SQLAlchemy незаменима. С примерами моделей, запросов и миграций.

18 июля 2026 г.
580
Создание Telegram бота в 2026 легко и просто! Полный курсы!
создание

Создание Telegram бота в 2026 легко и просто! Полный курсы!

19 июня 2026 г.
1171
Нужно ли мне знать математику, чтобы начать программировать?
знать

Нужно ли мне знать математику, чтобы начать программировать?

Разберём, где математика реально нужна (и где нет) для новичка: основы Python/веб/автоматизация/аналитика. В конце составим понятный маршрут обучения без лишней теории и подскажем, что повторить, если вы чувствуете пробелы.

28 сентября 2026 г.
30
FastAPI и фоновые задачи: когда лучше использовать BackgroundTasks, а когда очередь
fastapi

FastAPI и фоновые задачи: когда лучше использовать BackgroundTasks, а когда очередь

Поймём различия между синхронной обработкой, BackgroundTasks и внешними очередями. Разберём idempotency, ретраи и мониторинг фоновых процессов.

22 июля 2026 г.
810
Что такое переменная простыми словами: примеры из жизни и первый код
такое

Что такое переменная простыми словами: примеры из жизни и первый код

Разберём, что такое переменная без терминов: как “хранить” значение в памяти и как читать/менять его в программе. Дальше — мини-примеры на вводе/выводе и задания для новичка, чтобы закрепить понимание прямо в коде.

25 сентября 2026 г.
90

Комментарии

Пока нет комментариев

Содержание

Постановка задачи: что должна уметь утилитаАрхитектура проекта: “правильные” слои для CLIПодготовка зависимостей и конфигурацииПример pyproject.tomlМодель конфигурации: типы как контрактМодель: src/appctl/models.pyРабота с конфигами: чтение, запись, применение обновленийIO-утилиты: src/appctl/ioutils.pyДоменный слой: конфиг как объект, а не строкаsrc/appctl/config.pyUX: интерактивные вопросы и подтвержденияsrc/appctl/ux.pyCLI слой: Typer-команды с правильной обработкой ошибокБазовая структура: src/appctl/cli.pyКоманда init: интерактивные сценарии + сохранение конфигаРеализация init: src/appctl/cli.pyКоманда show: чтение и понятный выводКоманда set: частичное обновление с валидациейРеализация setКоманда deploy: подтверждения опасного действия и dry-runРеализация deployКоманда clean: удаление конфигурации с обязательным подтверждениемУдобные сообщения об ошибках: что делать, когда всё пошло не такТестирование логики конфиговtests/testconfig.pyПриведение к виду “для команды и CI”1) Единый формат параметров и выходных кодов2) Меньше “магии”, больше предсказуемых путей3) Настройка линтеров/форматтеров4) Безопасность интерактивности в CI5) Документация через --helpКак “дожать” пример до уровня productionИтог: почему Typer удобен именно для “живых” утилит