CLI-утилиты для разработчиков на Python с Typer: интерактивные сценарии, подтверждения и сохранение конфигов
Соберём пример CLI-проекта с Typer: интерактивные вопросы, подтверждения опасных действий, автозагрузка конфигов из файлов, валидация параметров и удобные сообщения об ошибках. Доведём до вида, пригодного для команды и CI.
Содержание
CLI-утилиты для разработчиков на Python с Typer: интерактивные сценарии, подтверждения и сохранение конфигов
Python давно перестал быть “языком для скриптов”. В реальной команде он закрывает и инфраструктурные задачи, и разработку внутренних тулов. Но если утилита не продумана как продукт для людей (а не просто для вас), она быстро превращается в источник ошибок: непредсказуемые аргументы, отсутствие интерактивности, “опасные” команды без подтверждений, сломанные конфиги и непонятные сообщения об ошибках.
Typer — один из самых практичных способов сделать CLI на Python: он опирается на типы (type hints), сам генерирует help, поддерживает интерактивные сценарии и хорошо ложится на современный стек (Pydantic для схем, Rich для UX, единый формат конфигов и валидации). В этой статье мы соберём небольшой, но “боевой” CLI-проект: интерактивные вопросы, подтверждения опасных действий, автозагрузка конфигов из файлов, валидация параметров и удобные сообщения об ошибках. Доведём код до состояния, пригодного для команды и для CI.
Постановка задачи: что должна уметь утилита
Сценарий будет приближен к реальности: есть конфигурация сервиса, которую мы должны уметь:
- Загружать из файлов (например, YAML/JSON), плюс поддерживать переопределения через аргументы CLI.
- Собирать интерактивные ответы при запуске без некоторых параметров.
- Запрашивать подтверждение для опасных действий (например, “удалить конфиг”, “перезаписать существующий файл”, “выполнить миграцию”).
- Валидировать входные параметры до того, как они попадут в файловую систему.
- Покрывать понятными ошибками: что именно неверно и как исправить.
- Быть пригодной для команды и CI: лаконичная структура, предсказуемые зависимости, тестируемость и единый формат сообщения об ошибках.
Чтобы не раздувать статью, мы сделаем утилиту appctl с командами:
init: создать конфиг по шаблону (с интерактивными вопросами).set: обновить значения в конфиге (с валидацией).deploy: имитировать “опасное” действие, требующее подтверждения (и опционально работать в “безопасном” режиме dry-run).show: показать текущий конфиг.clean: удалить конфиг (тут подтверждение обязательно).
Архитектура проекта: “правильные” слои для CLI
Даже небольшой CLI стоит разделить на слои, иначе он начинает “жить” в одном main.py, а потом вам приходится отлаживать хаос. В практическом подходе удобно выделить:
- CLI слой: команды Typer, парсинг аргументов, сообщения пользователю.
- Схема/валидация: Pydantic модели.
- Доменный слой: чтение/запись конфигов, применение патчей.
- UX слой: интерактивные вопросы, подтверждения, форматирование ошибок.
Структура, к которой мы придём:
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 и валидации нам пригодятся:
typerpydantic(или pydantic v2)rich(для красивых сообщений и удобного форматирования)pyyaml(если будем поддерживать YAML)typing-extensions(не обязательно, зависит от версий)- тестовый стек:
pytest
Пример pyproject.toml
[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
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
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
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
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.
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
@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.
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
@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
@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 позволяет пропустить.
@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
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-проектах обычно добавляют:
- Разделение “патчевых” моделей:
AppConfigPatchдляset, чтобы валидация была более точной и сообщения об ошибках не смешивались. - Поддержка конфигурации из нескольких источников:
- файл конфигурации;
- переменные окружения;
- аргументы CLI с приоритетами.
- Версионирование конфигурации: поле
schema_versionи миграции при загрузке. - Логи вместо print: интеграция
loggingдля CI. - Проверки наличия директории и “атомарная запись”: сохранять во временный файл и делать rename, чтобы не оставить поврежденный конфиг при сбое.
Если вы уже начинаете думать о таких улучшениях, это правильная траектория: CLI — инструмент, который живёт долго.
Итог: почему Typer удобен именно для “живых” утилит
Мы собрали небольшой CLI на Python с Typer, но главное — мы сделали то, что отличает “скрипт” от утилиты для команды:
- интерактивные вопросы там, где это экономит время и снижает ошибки;
- подтверждения опасных действий по умолчанию;
- чтение/сохранение конфигов в файловом формате с валидацией;
- централизованная и понятная обработка ошибок;
- предсказуемое поведение в CI через
--yesи--dry-run.
Если вы хотите углубиться в практики Typer (например, расширенные паттерны группировки команд, зависимостей, интерактивных сценариев, типизации и архитектуры CLI-проектов), полезным следующим шагом станет курс Typer PRO — как способ систематизировать знания и быстрее перейти от “работает у меня” к утилитам уровня команды.
Комментарии
Пока нет комментариев