Typer + конфиги: как сделать CLI, который дружит с Docker и CI
Научимся правильно организовывать параметры, env-переменные и конфиги из файлов, чтобы команда работала одинаково локально и в пайплайне. Добавим примеры для типовых задач сборки/деплоя.
Содержание
Typer + конфиги: как сделать CLI, который дружит с Docker и CI
Хороший CLI — это не набор “ключей” в стиле --help на всё подряд. Это интерфейс, который предсказуемо ведёт себя в разных средах: на ноутбуке разработчика, в Docker-образе, в CI-пайплайне, на проде под управлением orchestration-систем.
В этой статье разберём, как на Python и Typer выстроить параметры, env-переменные и конфигурации из файлов так, чтобы команда работала одинаково везде. Пойдём от практики: какие проблемы обычно возникают, какие решения “держатся” со временем, и как организовать конфиги, чтобы не ловить сюрпризы.
Будут примеры кода: типовые команды сборки/деплоя, чтение конфигурации, слой переопределений (CLI-параметры > env > файлы), и аккуратная работа с Docker и CI.
Почему CLI “ломается” в Docker и CI
Почти всегда проблема сводится к одной из трёх причин:
1) Конфигурация размазана по источникам
Например, часть настроек читается из env, часть — из YAML, а какие-то параметры задаются только через флаги. В локальной среде это может “случайно работать”, а в CI — нет.
2) Не определён приоритет источников
Если одновременно задано:
--image-tag v1.2.3(CLI),IMAGE_TAG=v1.2.4(env),imageTag: v1.2.5(файл),
— нужно чётко определить, что побеждает. Если порядок не зафиксирован, поведение будет случайным и трудно воспроизводимым.
3) Команда ожидает “файлы рядом”
В Docker контекст и текущая директория могут отличаться. Например, вы пишете CLI, который читает ./config.yaml, а внутри контейнера рабочая директория может быть другой или файл не примонтирован.
Архитектура конфигурации для Typer: общая модель
Хороший подход — рассматривать конфиг как объект, который собирается из нескольких слоёв с приоритетом. На практике удобнее всего:
- Значения, заданные через CLI (максимальный приоритет).
- Env-переменные (второй приоритет).
- Файл конфигурации (базовые дефолты).
- Мягкие значения по умолчанию в коде (самый низкий приоритет).
Так вы получаете предсказуемость: CI может задавать env, разработчик — переопределять флагами, а файл хранит “обычные” настройки.
Минимальный набор сущностей
Чтобы CLI не превратился в набор строк и os.environ.get(...), полезно ввести три компонента:
- Config: типизированная модель (например,
dataclassesилиpydantic). - Loader: функция, которая собирает Config из источников.
- Typer команда: тонкий слой, который либо принимает параметры, либо запускает бизнес-логику с уже собранной конфигурацией.
Выбор инструментов: dataclasses vs pydantic
Можно сделать всё на dataclasses и вручную, но на практике pydantic даёт выигрыш:
- типизация и валидация,
- удобная работа с источниками,
- меньше рутины при преобразованиях (str → int/bool).
В этой статье покажу пример с pydantic v2 (он современнее и хорошо подходит под задачу “сборки конфигурации”). Если вы не хотите pydantic — принципы те же, но придётся написать больше проверок вручную.
Реализация: единый слой конфигурации (файл + env + CLI)
Формат файла и соглашения об именах
Чтобы конфиг был переносим между локальной средой и CI, договоримся:
- файл:
config.yaml(можно заменить на JSON, но YAML удобнее для ручных правок), - env: переменные с префиксом
APP_(например,APP_REGISTRY,APP_TIMEOUT), - ключи в конфиге — в
snake_case.
Пример config.yaml:
registry: "registry.example.com"
repository: "my-service"
image_tag: "latest"
build_context: "."
dockerfile: "Dockerfile"
timeout_seconds: 900
deploy:
environment: "staging"
namespace: "apps"
Код: модель конфигурации и загрузчик
Ниже — рабочий каркас. Он:
- читает YAML (если указан путь),
- применяет env-переопределения,
- применяет CLI-параметры поверх всего,
- валидирует типы и значения.
Установка зависимостей
pip install typer pydantic pyyaml
Модель конфигурации (pydantic)
# config_model.py
from __future__ import annotations
from typing import Optional, Literal
from pydantic import BaseModel, Field
class DeployConfig(BaseModel):
environment: Literal["staging", "prod", "dev"] = "staging"
namespace: str = "apps"
class AppConfig(BaseModel):
registry: str = "registry.example.com"
repository: str = "my-service"
image_tag: str = "latest"
build_context: str = "."
dockerfile: str = "Dockerfile"
timeout_seconds: int = Field(default=900, ge=1)
deploy: DeployConfig = Field(default_factory=DeployConfig)
# базовые хелперы — не обязательно, но полезно для CLI
@property
def full_image(self) -> str:
return f"{self.registry}/{self.repository}:{self.image_tag}"
YAML/ENV загрузка и приоритеты
Сделаем loader максимально явным: порядок приоритетов — это важнейшая часть надёжности.
# config_loader.py
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Dict, Optional
import yaml
from pydantic import ValidationError
from config_model import AppConfig
def _read_yaml(path: Path) -> Dict[str, Any]:
if not path.exists():
raise FileNotFoundError(f"Config file not found: {path}")
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
raise ValueError(f"YAML config must be a mapping at root, got: {type(data)}")
return data
def _env_prefix_vars(prefix: str = "APP_") -> Dict[str, Any]:
"""
Превращает env вида APP_IMAGE_TAG → image_tag.
Поддерживает только верхний уровень ключей (registry, repository, image_tag...).
Для deploy.* делаем отдельную обработку ниже.
"""
result: Dict[str, Any] = {}
for k, v in os.environ.items():
if not k.startswith(prefix):
continue
key = k[len(prefix):].lower()
result[key] = v
return result
def _parse_boolish(value: str) -> Any:
# при необходимости — пример. В нашей модели bool не используется, но полезно как шаблон.
val = value.strip().lower()
if val in {"1", "true", "yes", "on"}:
return True
if val in {"0", "false", "no", "off"}:
return False
return value
def load_config(
*,
config_path: Optional[str] = None,
env_prefix: str = "APP_",
cli_overrides: Optional[Dict[str, Any]] = None,
) -> AppConfig:
"""
Приоритет:
1) cli_overrides
2) env
3) yaml config
4) значения по умолчанию в AppConfig
"""
file_data: Dict[str, Any] = {}
if config_path:
file_data = _read_yaml(Path(config_path))
env_data = _env_prefix_vars(env_prefix)
# deploy отдельного уровня
deploy_data: Dict[str, Any] = {}
# поддерживаем env APP_DEPLOY_ENVIRONMENT / APP_DEPLOY_NAMESPACE
deploy_env_env = os.environ.get(f"{env_prefix}DEPLOY_ENVIRONMENT")
deploy_env_ns = os.environ.get(f"{env_prefix}DEPLOY_NAMESPACE")
if deploy_env_env:
deploy_data["environment"] = deploy_env_env
if deploy_env_ns:
deploy_data["namespace"] = deploy_env_ns
merged: Dict[str, Any] = {}
merged.update(file_data) # yaml base
merged.update(env_data) # env overrides top-level keys
if deploy_data:
merged.setdefault("deploy", {})
merged["deploy"].update(deploy_data) # env overrides nested deploy
if cli_overrides:
# намеренно обновляем dict, чтобы CLI мог перезаписать даже nested структуры
merged.update({k: v for k, v in cli_overrides.items() if v is not None})
# если CLI может передавать nested deploy параметры, добавьте поддержку отдельно.
# Например: cli_overrides может содержать deploy_environment / deploy_namespace.
if "deploy_environment" in (cli_overrides or {}):
merged.setdefault("deploy", {})
if cli_overrides["deploy_environment"] is not None:
merged["deploy"]["environment"] = cli_overrides["deploy_environment"]
if "deploy_namespace" in (cli_overrides or {}):
merged.setdefault("deploy", {})
if cli_overrides["deploy_namespace"] is not None:
merged["deploy"]["namespace"] = cli_overrides["deploy_namespace"]
try:
return AppConfig.model_validate(merged)
except ValidationError as e:
# В CLI важно показывать понятные ошибки конфигурации
raise ValueError(f"Invalid configuration: {e}") from e
Ключевая идея: приоритеты зашиты в loader, а команды Typer используют уже готовый объект. Тогда вы не расползётесь по коду на os.environ.get в каждом обработчике.
Typer CLI: базовые команды сборки и деплоя
Теперь соберём “обвязку” CLI. Важно:
- параметры CLI сделаем опциональными, чтобы дефолты приходили из env/файла,
- добавим
--config(путь до файла), чтобы в Docker/CI можно было монтировать конфиг, - сделаем
--dry-runили “debug mode” для удобства диагностики в CI.
Каркас app.py
# app.py
from __future__ import annotations
import json
import subprocess
from pathlib import Path
from typing import Optional
import typer
from config_loader import load_config
app = typer.Typer(add_completion=False)
def _run(cmd: list[str], *, timeout_seconds: int) -> None:
typer.echo(f"+ {' '.join(cmd)}")
proc = subprocess.run(cmd, check=False, text=True, capture_output=True, timeout=timeout_seconds)
if proc.stdout:
typer.echo(proc.stdout)
if proc.stderr:
typer.echo(proc.stderr)
if proc.returncode != 0:
raise typer.Exit(code=proc.returncode)
@app.command()
def show(
config: Optional[str] = typer.Option(None, "--config", help="Path to config.yaml"),
registry: Optional[str] = typer.Option(None, "--registry"),
repository: Optional[str] = typer.Option(None, "--repository"),
image_tag: Optional[str] = typer.Option(None, "--image-tag"),
deploy_environment: Optional[str] = typer.Option(None, "--deploy-environment"),
deploy_namespace: Optional[str] = typer.Option(None, "--deploy-namespace"),
) -> None:
"""
Печатает итоговую конфигурацию после применения приоритетов:
CLI > env > файл.
Полезно для CI и дебага.
"""
cfg = load_config(
config_path=config,
cli_overrides={
"registry": registry,
"repository": repository,
"image_tag": image_tag,
"deploy_environment": deploy_environment,
"deploy_namespace": deploy_namespace,
},
)
typer.echo(cfg.model_dump_json(indent=2))
@app.command()
def build(
config: Optional[str] = typer.Option(None, "--config", help="Path to config.yaml"),
image_tag: Optional[str] = typer.Option(None, "--image-tag"),
no_cache: bool = typer.Option(False, "--no-cache", help="Pass --no-cache to docker build"),
dry_run: bool = typer.Option(False, "--dry-run", help="Show commands without executing"),
) -> None:
cfg = load_config(config_path=config, cli_overrides={"image_tag": image_tag})
cmd = [
"docker", "build",
"-f", cfg.dockerfile,
"-t", cfg.full_image,
cfg.build_context,
]
if no_cache:
cmd.insert(2, "--no-cache")
if dry_run:
typer.echo(f"(dry-run) Image: {cfg.full_image}")
typer.echo(f"(dry-run) CMD: {' '.join(cmd)}")
return
_run(cmd, timeout_seconds=cfg.timeout_seconds)
@app.command()
def deploy(
config: Optional[str] = typer.Option(None, "--config", help="Path to config.yaml"),
image_tag: Optional[str] = typer.Option(None, "--image-tag"),
environment: Optional[str] = typer.Option(None, "--environment", help="staging/prod/dev"),
namespace: Optional[str] = typer.Option(None, "--namespace"),
dry_run: bool = typer.Option(False, "--dry-run"),
) -> None:
cfg = load_config(
config_path=config,
cli_overrides={
"image_tag": image_tag,
"deploy_environment": environment,
"deploy_namespace": namespace,
},
)
# В реальности здесь будет интеграция с kubectl/helm/terraform.
# Ниже — “заглушка” для демонстрации структуры.
if dry_run:
typer.echo("(dry-run) would deploy:")
typer.echo(json.dumps({
"image": cfg.full_image,
"environment": cfg.deploy.environment,
"namespace": cfg.deploy.namespace,
}, indent=2))
return
# пример: kubectl set image (условно)
cmd = [
"kubectl",
"-n", cfg.deploy.namespace,
"set",
"image",
"deployment/my-service",
f"my-service={cfg.full_image}",
]
_run(cmd, timeout_seconds=cfg.timeout_seconds)
if __name__ == "__main__":
app()
Нюансы, которые важно соблюдать
- Параметры должны быть опциональными, иначе вы “перебьёте” env/файл значениями
Noneили пустыми строками. - Команда
show— это ваша страховка в CI. Когда пайплайн неожиданно использовал “не тот тег”, вы сможете быстро выяснить итоговую сборку конфигурации. dry_run— практичный инструмент: позволяет проверить логику выбора конфигурации и команду без побочных эффектов.
Как это работает в Docker: путь к конфигу и рабочая директория
Пример Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml ./
RUN pip install --no-cache-dir -e .
COPY . .
# Контейнер запускается с /app, но конфиг лучше монтировать явно
# и передавать путь через --config или env.
ENTRYPOINT ["python", "-m", "app"]
Запуск в контейнере
Вариант A: конфиг монтируем и передаём путь:
docker run --rm \
-v "$PWD/config.yaml:/config.yaml:ro" \
-v "$PWD:/src" \
-w /src \
mycli-image \
build --config /config.yaml --no-cache --image-tag "$TAG"
Вариант B: всё из env (удобно для CI):
docker run --rm \
-e APP_REGISTRY="registry.example.com" \
-e APP_REPOSITORY="my-service" \
-e APP_IMAGE_TAG="$TAG" \
-e APP_DEPLOY_ENVIRONMENT="staging" \
-e APP_DEPLOY_NAMESPACE="apps" \
mycli-image \
deploy
Здесь важно: чтобы Docker-окружение не требовало файлов “рядом” с бинарником. Лучше явно задавать путь к конфигу (--config) или полностью полагаться на env в пайплайне.
Как организовать CI: стабильно и без “магии”
Пример: GitHub Actions (идея такая же для GitLab CI)
Идея: в CI вы:
- задаёте env-параметры (регистры, теги, окружения),
- при желании передаёте конфиг файлом через workspace или как artefact,
- используете
showдля отладки.
# .github/workflows/deploy.yml
name: Deploy
on:
workflow_dispatch:
jobs:
build_and_deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Show config (debug)
run: |
python -m app show \
--registry "$REGISTRY" \
--repository "$REPOSITORY" \
--image-tag "$GITHUB_SHA" \
--deploy-environment "$ENVIRONMENT" \
--deploy-namespace "$NAMESPACE"
- name: Build image
run: |
python -m app build \
--image-tag "$GITHUB_SHA" \
--no-cache
- name: Deploy
env:
APP_REGISTRY: ${{ env.REGISTRY }}
APP_REPOSITORY: ${{ env.REPOSITORY }}
APP_IMAGE_TAG: ${{ github.sha }}
APP_DEPLOY_ENVIRONMENT: ${{ env.ENVIRONMENT }}
APP_DEPLOY_NAMESPACE: ${{ env.NAMESPACE }}
run: |
python -m app deploy
Типовые практики, которые экономят часы
1) Отдавайте предпочтение env для “секретов”, а конфиг — для структуры
Секреты (токены реестра, kubeconfig) в env/secret manager — стандарт. Конфиг пусть содержит “несекретные” параметры: пути, имена репозитор
Комментарии
Пока нет комментариев