$ 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
ГлавнаяБлогTyper + конфиги: как сделать CLI, который дружит с Docker и CI

Typer + конфиги: как сделать CLI, который дружит с Docker и CI

$ sudo teach IT
·24 июля 2026 г.·9 мин·43
Typer + конфиги: как сделать CLI, который дружит с Docker и CI

Научимся правильно организовывать параметры, env-переменные и конфиги из файлов, чтобы команда работала одинаково локально и в пайплайне. Добавим примеры для типовых задач сборки/деплоя.

Содержание
Почему CLI “ломается” в Docker и CI1) Конфигурация размазана по источникам2) Не определён приоритет источников3) Команда ожидает “файлы рядом”Архитектура конфигурации для Typer: общая модельМинимальный набор сущностейВыбор инструментов: dataclasses vs pydanticРеализация: единый слой конфигурации (файл + env + CLI)Формат файла и соглашения об именахКод: модель конфигурации и загрузчикУстановка зависимостейМодель конфигурации (pydantic)YAML/ENV загрузка и приоритетыTyper CLI: базовые команды сборки и деплояКаркас app.pyНюансы, которые важно соблюдатьКак это работает в Docker: путь к конфигу и рабочая директорияПример DockerfileЗапуск в контейнереКак организовать CI: стабильно и без “магии”Пример: GitHub Actions (идея такая же для GitLab 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: общая модель

Хороший подход — рассматривать конфиг как объект, который собирается из нескольких слоёв с приоритетом. На практике удобнее всего:

  1. Значения, заданные через CLI (максимальный приоритет).
  2. Env-переменные (второй приоритет).
  3. Файл конфигурации (базовые дефолты).
  4. Мягкие значения по умолчанию в коде (самый низкий приоритет).

Так вы получаете предсказуемость: 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:

code
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-параметры поверх всего,
  • валидирует типы и значения.

Установка зависимостей

code
pip install typer pydantic pyyaml

Модель конфигурации (pydantic)

code
# 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 максимально явным: порядок приоритетов — это важнейшая часть надёжности.

code
# 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

code
# 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

code
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: конфиг монтируем и передаём путь:

code
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):

code
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 для отладки.
code
# .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 — стандарт. Конфиг пусть содержит “несекретные” параметры: пути, имена репозитор

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

Автор

$ sudo teach IT

Typer PRO

Курс по теме

Typer PRO

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

Открыть курс

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

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

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

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

Перейти к курсу
Приложения для iPhone и Apple Watch на SwiftUI

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

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

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

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

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

Перейти к курсу
Приложения для macOS на SwiftUI

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

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

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

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

Пишем CLI для разработчика на Python: команды, конфиги и корректные exit codes по стандарту проекта
пишем

Пишем CLI для разработчика на Python: команды, конфиги и корректные exit codes по стандарту проекта

Построим удобный интерфейс командной утилиты: структура подкоманд, чтение конфигов, валидация входных параметров и единая логика ошибок с понятными exit code. Сфокусируемся на UX для тех, кто запускает CLI в CI и в скриптах.

15 сентября 2026 г.
1010
Docker Compose для реальных проектов: конфиги, сети, volume и воспроизводимые окружения
docker

Docker Compose для реальных проектов: конфиги, сети, volume и воспроизводимые окружения

Научимся поднимать набор сервисов одним файлом, правильно разделять окружения и делать так, чтобы «у меня работает» исчезло.

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

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

19 июня 2026 г.
1421
Как выбрать, куда идти в IT: веб, боты, данные — без хаоса
выбрать

Как выбрать, куда идти в IT: веб, боты, данные — без хаоса

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

5 октября 2026 г.
580
Путь от README до демо: как собрать портфолио разработчика, чтобы его хотели смотреть
путь

Путь от README до демо: как собрать портфолио разработчика, чтобы его хотели смотреть

Разберём структуру репозитория, как упаковать проект в понятную историю (проблема → решение → запуск → метрики качества) и какие артефакты повышают доверие: скринкасты, архитектурная схема, чек-лист продакшн-готовности.

17 сентября 2026 г.
1070
Как понять, какой язык программирования учить первым: простой тест и выбор
понять

Как понять, какой язык программирования учить первым: простой тест и выбор

Помогу новичку выбрать первый язык без стресса: объясню варианты по целям (веб, боты, анализ данных), что учить в первую очередь и как не потратить месяцы впустую. В конце — краткий план старта на ближайшие недели.

2 октября 2026 г.
630

Комментарии

Станьте первым в обсуждении

Поделитесь мнением о статье или задайте вопрос автору

Содержание

Почему CLI “ломается” в Docker и CI1) Конфигурация размазана по источникам2) Не определён приоритет источников3) Команда ожидает “файлы рядом”Архитектура конфигурации для Typer: общая модельМинимальный набор сущностейВыбор инструментов: dataclasses vs pydanticРеализация: единый слой конфигурации (файл + env + CLI)Формат файла и соглашения об именахКод: модель конфигурации и загрузчикУстановка зависимостейМодель конфигурации (pydantic)YAML/ENV загрузка и приоритетыTyper CLI: базовые команды сборки и деплояКаркас app.pyНюансы, которые важно соблюдатьКак это работает в Docker: путь к конфигу и рабочая директорияПример DockerfileЗапуск в контейнереКак организовать CI: стабильно и без “магии”Пример: GitHub Actions (идея такая же для GitLab CI)Типовые практики, которые экономят часы