Главная/Python внутри Container/Урок

6.8. CLI-приложение

Цели

После этого материала вы сможете:

  • контейнеризировать Python CLI так, чтобы аргументы доходили до приложения;
  • выбрать между ENTRYPOINT и CMD для CLI и обосновать выбор;
  • реализовать корректные коды возврата и объяснить их значение;
  • разделять результат и диагностику между stdout и stderr;
  • обрабатывать данные со стандартного ввода;
  • передавать конфигурацию через файл и переменные окружения;
  • сделать образ удобным для использования в конвейерах.

Предварительные знания

Рабочий пример этого урока — resources/examples/python-cli/.

Ключевые термины

ТерминОбъяснение
exit codeКод возврата процесса, 0–255
stdinСтандартный ввод, дескриптор 0
конвейерСоединение команд через канал: cmd1 | cmd2
argparseСтандартный модуль разбора аргументов командной строки
POSIX-совместимостьСледование соглашениям Unix-утилит

Теория

Чем CLI отличается от сервиса

Сервис работает постоянно и отвечает на запросы. CLI выполняет одну задачу и завершается. Это меняет требования.

СервисCLI
Время жизниДолгоеОдна операция
Основной выводЛогиРезультат работы
Коды возвратаОбычно 0Часть интерфейса
Стандартный вводНе используетсяЧасто источник данных
АргументыЗадаются один разМеняются при каждом запуске
HEALTHCHECKНуженБессмыслен
Restart policyunless-stoppedno

Последние две строки — частая ошибка: применение к CLI шаблона сервиса даёт бесконечный перезапуск успешно выполнившейся задачи (урок 4.6).

ENTRYPOINT для CLI

Для CLI правильная конструкция — ENTRYPOINT с программой и CMD с аргументами по умолчанию:

dockerfile
ENTRYPOINT ["wordfreq"]
CMD ["--help"]

Это даёт естественный интерфейс: образ ведёт себя как сама утилита.

bash
docker run --rm wordfreq              # выполнится: wordfreq --help
docker run --rm wordfreq --top 5      # выполнится: wordfreq --top 5

Альтернатива — только CMD — требует повторять имя программы:

bash
docker run --rm wordfreq wordfreq --top 5

Цена ENTRYPOINT: чтобы запустить в образе что-то другое, нужен --entrypoint. Для образа-инструмента это приемлемо.

Коды возврата как интерфейс

Для CLI код возврата — не формальность, а способ сообщить результат вызывающей стороне. Соглашения Unix:

КодЗначение
0Успех
1Общая ошибка выполнения
2Ошибка использования: неверные аргументы
3125Специфичные для приложения
126, 127Занято Docker и оболочкой (урок 4.6)
128+NЗавершение сигналом

Диапазон 3125 свободен для собственных кодов. Их стоит документировать: 4 — «файл не найден», 5 — «недостаточно данных».

Модуль argparse возвращает 2 при неверных аргументах автоматически — это соответствует соглашению.

Разделение stdout и stderr

Правило Unix: stdout — результат, stderr — всё остальное.

text
   результат работы          ──► stdout   (можно передать дальше по конвейеру)
   диагностика, прогресс     ──► stderr   (виден человеку, не мешает конвейеру)
   предупреждения, ошибки    ──► stderr

Это позволяет использовать утилиту в конвейере:

bash
docker run --rm -i wordfreq --top 3 < text.txt | head -1

Если бы диагностика шла в stdout, она попала бы в head и испортила результат.

Проверка: команда 2>/dev/null должна оставить только полезные данные.

Стандартный ввод

CLI, читающий stdin, требует флага -i при запуске (урок 4.2):

bash
echo "данные" | docker run --rm -i myimage

Без -i стандартный ввод закрыт, и приложение прочитает конец файла.

Флаг -t при этом не нужен и вреден: он сливает stdout и stderr, ломая разделение потоков, и добавляет \r в вывод.

Хорошая практика — поддерживать оба режима: файл как аргумент и stdin при его отсутствии.

Конфигурация

Для CLI приоритет источников конфигурации:

text
   значения по умолчанию в коде
          ↓ перекрываются
   файл конфигурации
          ↓ перекрываются
   переменные окружения
          ↓ перекрываются
   аргументы командной строки

Аргументы имеют высший приоритет — это соответствует ожиданиям пользователя: явно указанное побеждает.

Особенности запуска в container

ОсобенностьСледствие
Файлы host недоступныНужен bind mount для входных данных
Рабочий каталог — из образаОтносительные пути разрешаются иначе
Вывод в файл идёт в writable layerИсчезает после --rm; нужен volume
UID процесса влияет на созданные файлыФайлы результата принадлежат этому UID

Последняя строка важна: если CLI пишет результат в примонтированный каталог, владелец файла определяется UID процесса в container (урок 6.7).


Внутренний механизм

Как аргументы доходят до приложения

При docker run image arg1 arg2 Docker формирует итоговую команду (урок 5.4):

text
   Entrypoint + (аргументы docker run вместо Cmd)

Поэтому:

text
   ENTRYPOINT ["wordfreq"]   +   arg1 arg2   →   wordfreq arg1 arg2

Если ENTRYPOINT записан в shell form, аргументы теряются — это разбиралось в уроке 5.4 и проверяется в практической части.

Почему argparse возвращает 2

Модуль следует соглашению Unix, где код 2 означает ошибку использования. При неверных аргументах он печатает сообщение в stderr, справку и вызывает sys.exit(2).

Это поведение можно переопределить, но обычно не нужно — оно правильное.


Команды и примеры

Рабочий пример

Полный код — в resources/examples/python-cli/. Разберём его ключевые решения.

bash
cd resources/examples/python-cli
ls -R --ignore=__pycache__ | head -20
text
.:
Dockerfile  pyproject.toml  README.md  src  tests  wordfreq.example.toml

./src:
wordfreq

./src/wordfreq:
__init__.py  __main__.py  cli.py  config.py

./tests:
test_cli.py

Сборка:

bash
docker build -q -t wordfreq . > /dev/null && echo "образ собран"

Аргументы доходят до приложения

bash
echo "=== без аргументов: сработает CMD ==="
docker run --rm wordfreq 2>&1 | head -3

echo
echo "=== с аргументами: CMD заменяется ==="
echo "кот пёс кот кот пёс мышь" | docker run --rm -i wordfreq --top 2
text
=== без аргументов: сработает CMD ===
usage: wordfreq [-h] [-c CONFIG] [-n TOP] [--min-length MIN_LENGTH] [--version] [path]

Подсчёт частоты слов в тексте.

=== с аргументами: CMD заменяется ===
кот  3
пёс  2

Обратите внимание на -i: без него стандартный ввод закрыт.

Что было бы при shell form

bash
mkdir -p /tmp/cli-demo && cd /tmp/cli-demo

cat > Dockerfile.shell <<'EOF'
FROM python:3.13-slim
RUN pip install --no-cache-dir --quiet --root-user-action=ignore /dev/null 2>/dev/null || true
COPY probe.py /probe.py
ENTRYPOINT python /probe.py
EOF

cat > Dockerfile.exec <<'EOF'
FROM python:3.13-slim
COPY probe.py /probe.py
ENTRYPOINT ["python", "/probe.py"]
EOF

cat > probe.py <<'PY'
import sys
print(f"полученные аргументы: {sys.argv[1:]}")
PY

for v in shell exec; do
    docker build -q -f "Dockerfile.$v" -t "cli:$v" . > /dev/null
    printf '%-6s ' "$v"
    docker run --rm "cli:$v" --top 5 --verbose
done
text
shell  полученные аргументы: []
exec   полученные аргументы: ['--top', '5', '--verbose']

При shell form аргументы потеряны. Причина разбиралась в уроке 5.4: /bin/sh -c "команда" принимает только первый аргумент как команду.

Проверка чужого образа:

bash
for v in shell exec; do
    printf '%-6s Entrypoint = %s\n' "$v" \
        "$(docker image inspect "cli:$v" --format '{{json .Config.Entrypoint}}')"
done
text
shell  Entrypoint = ["/bin/sh","-c","python /probe.py"]
exec   Entrypoint = ["python","/probe.py"]

Наличие /bin/sh","-c" — признак проблемы.

Коды возврата

bash
cd resources/examples/python-cli

run_and_report() {
    local label="$1"; shift
    "$@" > /dev/null 2>&1
    printf '  %-32s код: %s\n' "$label" "$?"
}

echo "=== коды возврата ==="
run_and_report "успех" sh -c 'echo "текст" | docker run --rm -i wordfreq'
run_and_report "файл не найден" docker run --rm wordfreq /nonexistent.txt
run_and_report "неверный аргумент" docker run --rm wordfreq --no-such-option
run_and_report "недопустимое значение" sh -c 'echo x | docker run --rm -i wordfreq --top 0'
text
=== коды возврата ===
  успех                            код: 0
  файл не найден                   код: 1
  неверный аргумент                код: 2
  недопустимое значение            код: 1

Код 2 для неверного аргумента выдал argparse автоматически — это соответствует соглашению Unix.

Практическое применение кодов в скрипте:

bash
process_or_fail() {
    local input="$1"
    if echo "$input" | docker run --rm -i wordfreq --top 3 2>/dev/null; then
        echo "  обработано успешно"
    else
        case $? in
            1) echo "  ошибка выполнения: проверьте входные данные" ;;
            2) echo "  ошибка использования: проверьте аргументы" ;;
            *) echo "  неизвестная ошибка" ;;
        esac
    fi
}

process_or_fail "слово слово другое"
text
слово   2
другое  1
  обработано успешно

Разделение stdout и stderr

bash
echo "=== полный вывод ==="
echo "раз два раз три" | docker run --rm -i wordfreq --top 2

echo
echo "=== только stdout (результат) ==="
echo "раз два раз три" | docker run --rm -i wordfreq --top 2 2>/dev/null

echo
echo "=== только stderr (диагностика) ==="
echo "раз два раз три" | docker run --rm -i wordfreq --top 2 2>&1 1>/dev/null
text
=== полный вывод ===
всего уникальных слов: 3
раз  2
два  1

=== только stdout (результат) ===
раз  2
два  1

=== только stderr (диагностика) ===
всего уникальных слов: 3

Разделение работает — результат можно передать дальше по конвейеру, не получив в него диагностику:

bash
echo "яблоко груша яблоко слива яблоко груша" \
    | docker run --rm -i wordfreq --top 5 2>/dev/null \
    | head -1
text
яблоко  3

Почему -t ломает конвейер

bash
echo "=== без -t ==="
echo "а б а" | docker run --rm -i wordfreq --top 1 2>/dev/null | od -c | head -1

echo "=== с -t ==="
echo "а б а" | docker run --rm -i -t wordfreq --top 1 2>/dev/null | od -c | head -1
text
=== без -t ===
0000000    а      342 200 242    2  \n
=== с -t ===
0000000    в   с   е   г   о       у   н   и   к   а   л   ь   н   ы   х

С -t в stdout попала диагностика — потоки слились (урок 4.2). Конвейер получил не то, что ожидалось.

Правило: -i для данных, -t только для интерактивной работы человека.

Работа с файлами

bash
mkdir -p /tmp/cli-data && cd /tmp/cli-data
cat > input.txt <<'EOF'
Кот сидел на окне. Кот смотрел на птиц.
Птицы летали высоко. Кот мечтал о птицах.
EOF

echo "=== файл через bind mount ==="
docker run --rm -v "$PWD/input.txt:/data/input.txt:ro" \
    wordfreq /data/input.txt --top 3 --min-length 3
text
всего уникальных слов: 12
кот    3
птиц   1
птицы  1

Обратите внимание на :ro — входной файл монтируется только для чтения. Это правильно: CLI не должен его изменять.

Запись результата в примонтированный каталог:

bash
mkdir -p output
docker run --rm \
    -v "$PWD/input.txt:/data/input.txt:ro" \
    -v "$PWD/output:/out" \
    --entrypoint sh wordfreq \
    -c 'wordfreq /data/input.txt --top 3 > /out/result.txt 2>/dev/null'

ls -ln output/
cat output/result.txt
text
-rw-r--r-- 1 10001 10001 34 Jul 30 14:12 result.txt
кот    3
птиц   1
птицы  1

Файл принадлежит UID 10001 — тому пользователю, от которого работает приложение (урок 6.7). Если это не ваш UID на host, файл придётся удалять с sudo.

Обходной путь — задать UID при запуске:

bash
rm -rf output && mkdir -p output
docker run --rm \
    --user "$(id -u):$(id -g)" \
    -v "$PWD/input.txt:/data/input.txt:ro" \
    -v "$PWD/output:/out" \
    --entrypoint sh wordfreq \
    -c 'wordfreq /data/input.txt --top 2 > /out/result.txt 2>/dev/null'

ls -ln output/
echo "владелец совпадает с текущим пользователем: $(id -u):$(id -g)"
text
-rw-r--r-- 1 1000 1000 22 Jul 30 14:14 result.txt
владелец совпадает с текущим пользователем: 1000:1000

Флаг --user переопределяет USER из образа. Приём стандартный для CLI-образов, создающих файлы.

Конфигурация

bash
cd resources/examples/python-cli
cat wordfreq.example.toml
text
[wordfreq]
top = 5
min_length = 3

Три источника с возрастающим приоритетом:

bash
TEXT="один два три четыре пять шесть семь восемь"

echo "=== 1. значения по умолчанию (top=10) ==="
echo "$TEXT" | docker run --rm -i wordfreq 2>/dev/null | wc -l

echo "=== 2. файл конфигурации (top=5) ==="
echo "$TEXT" | docker run --rm -i \
    -v "$PWD/wordfreq.example.toml:/etc/wordfreq.toml:ro" \
    wordfreq --config /etc/wordfreq.toml 2>/dev/null | wc -l

echo "=== 3. переменная окружения (top=3) ==="
echo "$TEXT" | docker run --rm -i \
    -v "$PWD/wordfreq.example.toml:/etc/wordfreq.toml:ro" \
    -e WORDFREQ_TOP=3 \
    wordfreq --config /etc/wordfreq.toml 2>/dev/null | wc -l

echo "=== 4. аргумент командной строки (top=2) ==="
echo "$TEXT" | docker run --rm -i \
    -v "$PWD/wordfreq.example.toml:/etc/wordfreq.toml:ro" \
    -e WORDFREQ_TOP=3 \
    wordfreq --config /etc/wordfreq.toml --top 2 2>/dev/null | wc -l
text
=== 1. значения по умолчанию (top=10) ===
8
=== 2. файл конфигурации (top=5) ===
5
=== 3. переменная окружения (top=3) ===
3
=== 4. аргумент командной строки (top=2) ===
2

Каждый следующий источник перекрывает предыдущий. В первом случае вывелось 8 строк, а не 10, потому что уникальных слов всего восемь.

Удобство использования

Длинная команда docker run неудобна. Обёртка решает это:

bash
mkdir -p /tmp/cli-wrapper && cd /tmp/cli-wrapper

cat > wordfreq <<'SH'
#!/usr/bin/env bash
# Обёртка: делает образ похожим на обычную утилиту.
set -euo pipefail

IMAGE="${WORDFREQ_IMAGE:-wordfreq}"

args=(--rm)

# -i только если stdin не терминал (то есть данные идут из конвейера или файла)
[ -t 0 ] || args+=(-i)

# UID текущего пользователя: созданные файлы будут принадлежать ему
args+=(--user "$(id -u):$(id -g)")

# Текущий каталог доступен внутри как /work
args+=(-v "$PWD:/work" -w /work)

exec docker run "${args[@]}" "$IMAGE" "$@"
SH
chmod +x wordfreq

cp /tmp/cli-data/input.txt .

echo "=== использование как обычной утилиты ==="
./wordfreq input.txt --top 3
echo
echo "=== в конвейере ==="
echo "альфа бета альфа гамма" | ./wordfreq --top 2 2>/dev/null
text
=== использование как обычной утилиты ===
всего уникальных слов: 12
кот    3
птиц   1
птицы  1

=== в конвейере ===
альфа  2
бета   1

Обёртка решает четыре задачи: подставляет -i только когда нужно, задаёт UID, монтирует текущий каталог и делает вызов похожим на обычную команду.

Проверка [ -t 0 ] важна: добавлять -i всегда безопасно, но лишний -t испортил бы конвейер.

CLI не сервис

bash
echo "=== ошибка: restart policy для CLI ==="
docker run -d --name cli-loop --restart=always \
    --entrypoint sh wordfreq -c 'echo "задача выполнена"; exit 0' > /dev/null
sleep 8
printf '  перезапусков: %s\n' "$(docker inspect cli-loop --format '{{.RestartCount}}')"
printf '  выполнений в логах: %s\n' "$(docker logs cli-loop 2>&1 | grep -c 'задача выполнена')"
docker rm -f cli-loop > /dev/null
text
=== ошибка: restart policy для CLI ===
  перезапусков: 4
  выполнений в логах: 5

Успешно завершившаяся задача перезапускалась четыре раза. Для неидемпотентной операции — например, отправки уведомлений — это дало бы пять отправок вместо одной.

Правильно для CLI — политика по умолчанию no и флаг --rm.

Уборка

bash
cd /tmp
docker rmi -f cli:shell cli:exec wordfreq 2>/dev/null || true
rm -rf /tmp/cli-demo /tmp/cli-data /tmp/cli-wrapper

Практическое упражнение

Задание. Контейнеризируйте CLI-утилиту, удовлетворяющую семи требованиям.

Утилита csvstat читает CSV и выводит статистику по числовой колонке.

Требования:

  1. Аргументы docker run доходят до приложения.
  2. Читает файл-аргумент или стандартный ввод при его отсутствии.
  3. Результат в stdout, диагностика в stderr.
  4. Коды возврата: 0 успех, 1 ошибка данных, 2 неверные аргументы, 3 колонка не найдена.
  5. Работает от непривилегированного пользователя.
  6. Файлы, созданные в примонтированном каталоге, принадлежат вызывающему пользователю.
  7. Тесты запускаются как стадия сборки.

Напишите также скрипт-обёртку, делающий вызов похожим на обычную утилиту.

Подсказки

Подсказка 1

Требование 4 с кодом 3 требует явного sys.exit(3); argparse даст 2 автоматически.

Подсказка 2

Требование 6 решается не в образе, а флагом --user при запуске — обёртка подставляет его.

Подсказка 3

Для чтения stdin используйте sys.stdin при отсутствии аргумента пути.

Решение

Сначала выполните задание самостоятельно.

Показать решение
bash
mkdir -p /tmp/csvstat/src/csvstat /tmp/csvstat/tests && cd /tmp/csvstat

cat > src/csvstat/__init__.py <<'PY'
"""csvstat — статистика по числовой колонке CSV."""

__version__ = "1.0.0"
PY

cat > src/csvstat/cli.py <<'PY'
"""CLI: статистика по числовой колонке CSV.

Коды возврата:
    0 — успех
    1 — ошибка данных (файл не найден, некорректный CSV)
    2 — ошибка использования (выставляет argparse)
    3 — указанная колонка не найдена
"""
from __future__ import annotations

import argparse
import csv
import statistics
import sys
from pathlib import Path
from typing import TextIO

from . import __version__

EXIT_OK = 0
EXIT_DATA_ERROR = 1
EXIT_COLUMN_NOT_FOUND = 3


def compute(rows: list[dict[str, str]], column: str) -> dict[str, float]:
    """Считает статистику по колонке.

    Raises:
        KeyError: колонка отсутствует.
        ValueError: в колонке нет числовых значений.
    """
    if not rows:
        raise ValueError("нет строк данных")
    if column not in rows[0]:
        raise KeyError(column)

    values: list[float] = []
    skipped = 0
    for row in rows:
        raw = (row.get(column) or "").strip()
        if not raw:
            skipped += 1
            continue
        try:
            values.append(float(raw))
        except ValueError:
            skipped += 1

    if not values:
        raise ValueError(f"в колонке {column!r} нет числовых значений")

    return {
        "count": len(values),
        "skipped": skipped,
        "min": min(values),
        "max": max(values),
        "mean": statistics.fmean(values),
        "median": statistics.median(values),
    }


def format_stats(stats: dict[str, float]) -> str:
    order = ("count", "min", "max", "mean", "median")
    width = max(len(k) for k in order)
    return "\n".join(
        f"{k:<{width}}  {stats[k]:g}" if k == "count" else f"{k:<{width}}  {stats[k]:.4g}"
        for k in order
    )


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        prog="csvstat",
        description="Статистика по числовой колонке CSV.",
    )
    p.add_argument("path", nargs="?", type=Path,
                   help="файл CSV; без него читается стандартный ввод")
    p.add_argument("-c", "--column", required=True, help="имя колонки")
    p.add_argument("-d", "--delimiter", default=",", help="разделитель (по умолчанию запятая)")
    p.add_argument("--version", action="version", version=f"csvstat {__version__}")
    return p


def read_rows(stream: TextIO, delimiter: str) -> list[dict[str, str]]:
    return list(csv.DictReader(stream, delimiter=delimiter))


def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)

    try:
        if args.path is not None:
            if not args.path.is_file():
                print(f"ошибка: файл не найден: {args.path}", file=sys.stderr)
                return EXIT_DATA_ERROR
            with args.path.open(encoding="utf-8", newline="") as fh:
                rows = read_rows(fh, args.delimiter)
        else:
            rows = read_rows(sys.stdin, args.delimiter)
    except (OSError, csv.Error) as exc:
        print(f"ошибка чтения: {exc}", file=sys.stderr)
        return EXIT_DATA_ERROR

    try:
        stats = compute(rows, args.column)
    except KeyError:
        available = ", ".join(rows[0].keys()) if rows else "(нет данных)"
        print(f"ошибка: колонка {args.column!r} не найдена", file=sys.stderr)
        print(f"доступные колонки: {available}", file=sys.stderr)
        return EXIT_COLUMN_NOT_FOUND
    except ValueError as exc:
        print(f"ошибка: {exc}", file=sys.stderr)
        return EXIT_DATA_ERROR

    # результат — в stdout
    print(format_stats(stats))
    # диагностика — в stderr
    if stats["skipped"]:
        print(f"пропущено нечисловых значений: {stats['skipped']:g}", file=sys.stderr)
    return EXIT_OK
PY

cat > src/csvstat/__main__.py <<'PY'
import sys

from .cli import main

if __name__ == "__main__":
    sys.exit(main())
PY

cat > tests/test_cli.py <<'PY'
import io

import pytest

from csvstat.cli import compute, main


ROWS = [
    {"name": "a", "value": "10"},
    {"name": "b", "value": "20"},
    {"name": "c", "value": "30"},
]


def test_compute_basic():
    s = compute(ROWS, "value")
    assert s["count"] == 3
    assert s["min"] == 10
    assert s["max"] == 30
    assert s["mean"] == 20


def test_compute_skips_non_numeric():
    rows = ROWS + [{"name": "d", "value": "нечисло"}, {"name": "e", "value": ""}]
    s = compute(rows, "value")
    assert s["count"] == 3
    assert s["skipped"] == 2


def test_compute_missing_column_raises():
    with pytest.raises(KeyError):
        compute(ROWS, "нет-такой")


def test_compute_no_numeric_raises():
    with pytest.raises(ValueError, match="нет числовых"):
        compute([{"v": "x"}, {"v": "y"}], "v")


def test_main_reads_stdin(monkeypatch, capsys):
    monkeypatch.setattr("sys.stdin", io.StringIO("name,value\na,1\nb,3\n"))
    assert main(["--column", "value"]) == 0
    assert "mean" in capsys.readouterr().out


def test_main_missing_file_returns_1(capsys):
    assert main(["/nonexistent.csv", "--column", "v"]) == 1
    assert "не найден" in capsys.readouterr().err


def test_main_missing_column_returns_3(monkeypatch, capsys):
    monkeypatch.setattr("sys.stdin", io.StringIO("name,value\na,1\n"))
    assert main(["--column", "нет-такой"]) == 3
    err = capsys.readouterr().err
    assert "не найдена" in err
    assert "доступные колонки" in err


def test_main_no_numeric_returns_1(monkeypatch, capsys):
    monkeypatch.setattr("sys.stdin", io.StringIO("v\nx\ny\n"))
    assert main(["--column", "v"]) == 1


def test_parser_requires_column():
    with pytest.raises(SystemExit) as exc:
        main([])
    assert exc.value.code == 2


def test_result_to_stdout_diagnostics_to_stderr(monkeypatch, capsys):
    monkeypatch.setattr("sys.stdin", io.StringIO("v\n1\nx\n2\n"))
    assert main(["--column", "v"]) == 0
    out, err = capsys.readouterr()
    assert "mean" in out
    assert "mean" not in err
    assert "пропущено" in err
PY

cat > pyproject.toml <<'EOF'
[project]
name = "csvstat"
version = "1.0.0"
description = "Статистика по числовой колонке CSV"
requires-python = ">=3.11"
dependencies = []

[project.scripts]
csvstat = "csvstat.cli:main"

[build-system]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"

[tool.setuptools.packages.find]
where = ["src"]
EOF

cat > .dockerignore <<'EOF'
Dockerfile*
.dockerignore
README.md
csvstat
.git
.venv
__pycache__
*.py[cod]
.pytest_cache
*.csv
EOF

# tests не исключён: его копирует стадия test, а .dockerignore
# действует на всю сборку сразу (урок 5.1)

cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1

FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PATH="/opt/venv/bin:$PATH"
WORKDIR /work
RUN useradd --create-home --uid 10001 appuser

FROM base AS builder
RUN python -m venv /opt/venv
COPY pyproject.toml .
COPY src/ ./src/
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install .

# Требование 7: тесты как стадия сборки
FROM builder AS test
RUN --mount=type=cache,target=/root/.cache/pip pip install pytest==9.1.1
COPY tests/ ./tests/
RUN pytest -q tests/

FROM base AS runtime
COPY --from=builder --chown=10001:10001 /opt/venv /opt/venv
USER 10001:10001

# Требование 1: exec form — аргументы доходят до приложения
ENTRYPOINT ["csvstat"]
CMD ["--help"]
EOF

# ── Проверка требований ──
docker build -q -t csvstat . > /dev/null
echo "образ собран"

cat > sample.csv <<'EOF'
name,value,note
alpha,10,ok
beta,20,ok
gamma,30,ok
delta,,пусто
epsilon,нечисло,плохо
EOF

echo
echo "═══ Требование 1: аргументы доходят ═══"
docker run --rm -v "$PWD/sample.csv:/work/sample.csv:ro" csvstat sample.csv --column value

echo
echo "═══ Требование 2: чтение stdin ═══"
docker run --rm -i csvstat --column value < sample.csv 2>/dev/null

echo
echo "═══ Требование 3: разделение потоков ═══"
echo -n "  только stdout: "; docker run --rm -i csvstat --column value < sample.csv 2>/dev/null | tr '\n' ' '
echo
echo -n "  только stderr: "; docker run --rm -i csvstat --column value < sample.csv 2>&1 1>/dev/null

echo
echo "═══ Требование 4: коды возврата ═══"
docker run --rm -i csvstat --column value < sample.csv > /dev/null 2>&1; echo "  успех:            $?"
docker run --rm csvstat /nonexistent.csv --column v > /dev/null 2>&1; echo "  файл не найден:   $?"
docker run --rm csvstat --no-such-flag > /dev/null 2>&1; echo "  неверный аргумент: $?"
docker run --rm -i csvstat --column нет-такой < sample.csv > /dev/null 2>&1; echo "  колонки нет:      $?"

echo
echo "═══ Требование 5: непривилегированный пользователь ═══"
printf '  UID: %s\n' "$(docker run --rm --entrypoint id csvstat -u)"

echo
echo "═══ Требование 6: владелец созданных файлов ═══"
mkdir -p out
docker run --rm --user "$(id -u):$(id -g)" \
    -v "$PWD:/work" csvstat sample.csv --column value 2>/dev/null > out/result.txt
printf '  владелец файла: %s (текущий пользователь: %s:%s)\n' \
    "$(stat -c '%u:%g' out/result.txt)" "$(id -u)" "$(id -g)"

echo
echo "═══ Требование 7: тесты в сборке ═══"
docker build -q --target test -t csvstat:test . > /dev/null && echo "  тесты прошли"

# обёртка
cat > csvstat <<'SH'
#!/usr/bin/env bash
set -euo pipefail
IMAGE="${CSVSTAT_IMAGE:-csvstat}"
args=(--rm --user "$(id -u):$(id -g)" -v "$PWD:/work" -w /work)
[ -t 0 ] || args+=(-i)
exec docker run "${args[@]}" "$IMAGE" "$@"
SH
chmod +x csvstat

echo
echo "═══ Обёртка ═══"
./csvstat sample.csv --column value 2>/dev/null
echo "  ─── в конвейере ───"
./csvstat --column value < sample.csv 2>/dev/null | grep mean

Ожидаемый вывод:

text
образ собран

═══ Требование 1: аргументы доходят ═══
count   3
min     10
max     30
mean    20
median  20
пропущено нечисловых значений: 2

═══ Требование 2: чтение stdin ═══
count   3
min     10
max     30
mean    20
median  20

═══ Требование 3: разделение потоков ═══
  только stdout: count   3 min     10 max     30 mean    20 median  20 
  только stderr: пропущено нечисловых значений: 2

═══ Требование 4: коды возврата ═══
  успех:            0
  файл не найден:   1
  неверный аргумент: 2
  колонки нет:      3

═══ Требование 5: непривилегированный пользователь ═══
  UID: 10001

═══ Требование 6: владелец созданных файлов ═══
  владелец файла: 1000:1000 (текущий пользователь: 1000:1000)

═══ Требование 7: тесты в сборке ═══
  тесты прошли

═══ Обёртка ═══
count   3
min     10
max     30
mean    20
median  20
  ─── в конвейере ───
mean    20

Проверено прогоном 2026-08-04 на Docker Engine 29.7.1: вывод совпадает построчно, кроме требования 6 — там печатаются ваши uid:gid, и важно лишь то, что оба числа в строке одинаковы. Прогон нашёл в первой редакции решения дефект: .dockerignore исключал tests, из-за чего требование 7 не выполнялось никогда — docker build --target test завершался с "/tests": not found, а не прогонял тесты (урок 5.1).

Четыре решения, определяющие качество.

Код 3 для отсутствующей колонки, а не 1. Отдельный код позволяет вызывающему скрипту различить «данные плохие» и «попросили не ту колонку» — реакция на эти случаи разная. Вместе с кодом выводится список доступных колонок, что превращает ошибку в подсказку.

Проверка [ -t 0 ] в обёртке. Флаг -i добавляется только когда stdin не терминал. Добавлять его всегда безопасно, но так обёртка честно отражает намерение. Флаг -t не добавляется никогда — он слил бы потоки и сломал требование 3.

--user "$(id -u):$(id -g)" в обёртке, а не в образе. В образе задан UID 10001 — это правильно для запуска в оркестраторе. Но для CLI, создающего файлы на host, нужен UID вызывающего. Флаг при запуске решает обе задачи без компромисса.

Требование 3 проверяется тестом, а не только вручную. Тест test_result_to_stdout_diagnostics_to_stderr фиксирует разделение потоков в коде. Без него рефакторинг легко переведёт диагностику в stdout и сломает все конвейеры, использующие утилиту.

bash
cd /tmp && docker rmi -f csvstat csvstat:test > /dev/null 2>&1; rm -rf /tmp/csvstat

Проверка результата

bash
cd resources/examples/python-cli
docker build -q -t wf . > /dev/null
echo "а б а в а" | docker run --rm -i wf --top 2 2>/dev/null
echo "код возврата: $?"
docker run --rm wf --no-such-option > /dev/null 2>&1; echo "неверный аргумент: $?"
docker rmi -f wf > /dev/null

Ожидается результат без диагностики, коды 0 и 2.

Типичные ошибки

ОшибкаПричинаИсправление
ENTRYPOINT в shell formКороче писатьАргументы docker run теряются
Забыт -i при чтении stdinНе задумывалисьПриложение получает конец файла сразу
-it в конвейереСкопировано из интерактивных примеров-t сливает потоки и добавляет \r
Диагностика в stdoutПроще писать printПортит конвейер; использовать file=sys.stderr
Всегда код 0 или 1Не задумывались о кодахКод возврата — часть интерфейса CLI
HEALTHCHECK в CLI-образеКопирование шаблона сервисаДля разовой задачи бессмыслен
--restart=always для CLIКажется надёжнееУспешная задача перезапускается бесконечно
Забыт --rmНе задумывалисьНакапливаются остановленные containers
Файлы результата принадлежат чужому UIDUID из образа--user "$(id -u):$(id -g)" при запуске
Входной файл смонтирован на записьКопируют шаблонИспользовать :ro

Контрольные вопросы

На понимание:

  1. Почему для CLI предпочтителен ENTRYPOINT, а не только CMD?
  2. Почему argparse возвращает код 2 при неверных аргументах?
  3. Почему диагностика должна идти в stderr, а не в stdout?
  4. Почему -t ломает использование CLI в конвейере?
  5. Чем требования к CLI-образу отличаются от требований к образу сервиса?

На применение:

  1. Как передать данные на стандартный ввод приложения в container?
  2. Как обеспечить, чтобы созданные файлы принадлежали текущему пользователю host?
  3. Как сделать вызов образа похожим на обычную утилиту?

На диагностику:

  1. Аргументы, переданные в docker run, игнорируются. Что проверить первым?
  2. CLI в конвейере выдаёт лишние строки, хотя 2>/dev/null указан. Причина?

Краткое резюме

  1. CLI отличается от сервиса: разовое выполнение, коды возврата как интерфейс, чтение stdin.
  2. Для CLI используется ENTRYPOINT с программой и CMD с аргументами по умолчанию.
  3. ENTRYPOINT обязательно в exec form — иначе аргументы теряются.
  4. Коды возврата: 0 успех, 1 ошибка выполнения, 2 неверные аргументы, 3125 свои.
  5. Результат идёт в stdout, диагностика — в stderr; это позволяет использовать конвейер.
  6. Флаг -i нужен для чтения stdin, -t в конвейере вреден.
  7. Входные файлы монтируются с :ro, выходные — в отдельный каталог.
  8. Владелец созданных файлов определяется UID процесса; --user переопределяет его.
  9. HEALTHCHECK и restart policy для CLI не применяются.
  10. Скрипт-обёртка делает образ удобным: подставляет флаги и монтирует рабочий каталог.

Официальные источники

ИсточникСсылкаЧто подтверждает
Dockerfile reference: ENTRYPOINThttps://docs.docker.com/reference/dockerfile/#entrypointВзаимодействие с CMD, exec form, передача аргументов
docker run referencehttps://docs.docker.com/reference/cli/docker/container/run/Флаги -i, -t, --rm, --user, --entrypoint
Python: argparsehttps://docs.python.org/3/library/argparse.htmlРазбор аргументов, код возврата 2 при ошибке использования
Python: syshttps://docs.python.org/3/library/sys.htmlsys.stdin, sys.stderr, sys.exit
Python: csvhttps://docs.python.org/3/library/csv.htmlDictReader, обработка ошибок формата
GNU Coding Standards: exit statushttps://www.gnu.org/prep/standards/html_node/Exit-Status.htmlСоглашения о кодах возврата
Building best practiceshttps://docs.docker.com/build/building/best-practices/Рекомендации по ENTRYPOINT для образов-инструментов
Docker storage: bind mountshttps://docs.docker.com/engine/storage/bind-mounts/Монтирование файлов, режим :ro

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Flask
Главное оглавление

Markdown на GitHub ↗