10.5. Автоматизация
Цели
После этого материала вы сможете:
- написать
Makefile, который остаётся понятным через полгода; - объяснить, почему
Makefileудобнее набора скриптов и где его граница; - запускать
ruff,mypyиpytestодинаково локально и в CI; - держать конфигурацию инструментов в одном месте;
- встроить проверки в
pre-commitв контейнеризованном проекте; - понимать, когда контейнеризация инструментов мешает больше, чем помогает.
Предварительные знания
- 10.1. Development и production образы;
- 6.12. Тестирование в container;
- 9.6. Несколько файлов и profiles.
Ключевые термины
| Термин | Объяснение |
|---|---|
цель | Именованная команда Makefile: make test |
.PHONY | Цель, не создающая файла с таким именем |
pre-commit | Инструмент запуска проверок перед коммитом |
единый источник правды | Одно место, откуда конфигурация берётся всеми |
pyproject.toml | Стандартный файл конфигурации Python-проекта |
Теория
Зачем Makefile
В контейнеризованном проекте команды длинные:
docker compose -f compose.yaml -f compose.test.yaml run --rm \
-e PYTHONPATH=/app --no-deps tests pytest -q --cov=app tests/
Такую строку не набирают — её копируют из чужого сообщения, теряя половину флагов.
Что даёт Makefile | Пояснение |
|---|---|
| Короткие имена | make test вместо строки выше |
| Самодокументирование | make help перечисляет доступное |
| Единая точка правды | CI вызывает то же, что и разработчик |
| Зависимости между целями | make test сам выполнит make build |
| Работает везде | make есть на любой Linux-машине |
Последний пункт — довод против самописных run.sh: make уже установлен, его синтаксис известен, а поведение предсказуемо.
Границы Makefile
| Задача | Makefile | Скрипт |
|---|---|---|
| Запуск команды с флагами | Да | Избыточно |
| Цепочка из двух-трёх шагов | Да | — |
| Условная логика, циклы | Плохо | Да |
| Разбор аргументов | Плохо | Да |
| Интерактивный ввод | Нет | Да |
| Больше 20 строк в одной цели | Плохо | Да |
Практическое правило: Makefile вызывает скрипты, а не заменяет их. Цель длиннее пяти строк — повод вынести тело в scripts/.
Особенности синтаксиса, о которые спотыкаются
| Особенность | Последствие |
|---|---|
| Отступ — табуляция, не пробелы | Makefile:5: *** missing separator |
| Каждая строка — отдельный shell | cd в одной строке не влияет на следующую |
Переменные $ нужно удваивать | $$HOME для переменной shell |
Цель без .PHONY | Не выполнится, если существует файл с таким именем |
make останавливается на первой ошибке | Обычно желаемое поведение |
Вторая строка объясняет типичную ошибку:
broken:
cd build # этот cd не действует на следующую строку
./run.sh
works:
cd build && ./run.sh
.PHONY обязателен
.PHONY: test build clean
Без него make test не выполнится, если в каталоге есть файл или каталог test: make решит, что цель уже собрана.
Каталог tests/ при цели test не конфликтует, а при цели tests — конфликтует. Проще объявлять .PHONY для всех служебных целей сразу.
Единый источник правды для инструментов
Конфигурация линтеров должна лежать в одном месте и читаться одинаково локально, в container и в CI.
# pyproject.toml — единственный источник
[tool.ruff]
line-length = 100
target-version = "py313"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
[tool.mypy]
python_version = "3.13"
strict = true
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q --strict-markers"
| Антипаттерн | Почему плохо |
|---|---|
Флаги в командной строке Makefile | CI вызывает иначе — результат расходится |
Отдельный setup.cfg и pyproject.toml | Два места, одно устареет |
| Разные версии инструментов локально и в CI | «У меня проходит» |
Последняя строка — главная. Версии инструментов фиксируют в requirements-dev.txt и устанавливают в dev-образ, а не глобально (урок 10.1).
Запускать инструменты в container или локально
| В container | Локально | |
|---|---|---|
| Одинаковая версия у всех | Да | Нет |
| Скорость запуска | Ниже (накладные расходы) | Выше |
| Интеграция с IDE | Сложнее | Проще |
| Права на созданные файлы | Требуют внимания | Нормальные |
| Работает без Python на host | Да | Нет |
Рабочий компромисс: в CI — в container, локально — как удобно, но версии одни и те же.
Инструмент, переформатирующий файлы (ruff format, black), в container требует совпадения UID, иначе изменённые файлы окажутся чужими (урок 7.5).
pre-commit в контейнеризованном проекте
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: local
hooks:
- id: mypy-in-docker
name: mypy (в container)
entry: docker compose run --rm --no-deps tools mypy app
language: system
pass_filenames: false
types: [python]
Два подхода в одном файле:
| Подход | Когда |
|---|---|
| Готовый repo с версией | Инструмент быстрый, окружение не нужно |
local плюс docker compose run | Нужны зависимости проекта — например, для mypy |
mypy требует установленных пакетов, чтобы разбирать их типы. Запускать его вне окружения проекта бессмысленно — отсюда второй вариант.
Флаг pass_filenames: false нужен, потому что пути внутри container отличаются от путей на host.
Что должно быть в make help
Самодокументирование окупается на второй неделе проекта:
help: ## показать доступные команды
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
Комментарий с ## после цели становится описанием. Список не устаревает, потому что живёт рядом с самими целями.
Внутренний механизм
Почему make требует табуляцию
Синтаксис унаследован от первой реализации 1976 года. Автор позже признавал это ошибкой, но к тому времени существовало слишком много Makefile.
Практическое следствие: в редакторе для Makefile отключают замену табуляции пробелами. В .editorconfig:
[Makefile]
indent_style = tab
Как make определяет, нужно ли выполнять цель
По умолчанию make сравнивает время изменения цели и её зависимостей. Цель, не являющаяся файлом, всегда считается устаревшей — но только если объявлена в .PHONY.
Без .PHONY и при наличии одноимённого файла make выведет:
make: 'test' is up to date.
и ничего не выполнит. Ошибка тихая и сбивает с толку.
Команды и примеры
Makefile для контейнеризованного проекта
mkdir -p /tmp/autom/app /tmp/autom/tests /tmp/autom/scripts && cd /tmp/autom
cat > app/__init__.py <<'PY'
"""Приложение для демонстрации автоматизации."""
PY
cat > app/calc.py <<'PY'
"""Модуль с намеренными нарушениями стиля и типов."""
from __future__ import annotations
def add(a: int, b: int) -> int:
return a + b
def divide(a: float, b: float) -> float:
if b == 0:
raise ZeroDivisionError("деление на ноль")
return a / b
PY
cat > tests/test_calc.py <<'PY'
import pytest
from app.calc import add, divide
def test_add():
assert add(2, 3) == 5
def test_divide():
assert divide(10, 4) == 2.5
def test_divide_by_zero():
with pytest.raises(ZeroDivisionError):
divide(1, 0)
PY
cat > pyproject.toml <<'EOF'
[project]
name = "autom"
version = "0.1.0"
requires-python = ">=3.13"
# ── Единый источник правды для всех инструментов ──
[tool.ruff]
line-length = 100
target-version = "py313"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
[tool.mypy]
python_version = "3.13"
strict = true
files = ["app"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q --strict-markers"
[tool.coverage.run]
source = ["app"]
branch = true
[tool.coverage.report]
show_missing = true
fail_under = 80
EOF
cat > requirements.txt <<'EOF'
click==8.3.0
EOF
cat > requirements-dev.txt <<'EOF'
-r requirements.txt
pytest==9.1.1
pytest-cov==7.1.0
ruff==0.16.0
mypy==1.19.1
EOF
cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 PATH="/opt/venv/bin:$PATH"
RUN python -m venv /opt/venv
WORKDIR /app
FROM base AS builder
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
FROM builder AS dev
ARG UID=1000
ARG GID=1000
COPY requirements-dev.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements-dev.txt
RUN groupadd -g ${GID} dev 2>/dev/null || true; \
useradd -u ${UID} -g ${GID} -m -d /home/dev dev 2>/dev/null || true; \
mkdir -p /home/dev && chown -R ${UID}:${GID} /home/dev /app
ENV HOME=/home/dev
USER ${UID}:${GID}
CMD ["python", "-m", "app.calc"]
FROM base AS runtime
RUN useradd --create-home --uid 10001 appuser
COPY --from=builder --chown=10001:10001 /opt/venv /opt/venv
COPY --chown=10001:10001 app/ ./app/
USER 10001:10001
CMD ["python", "-m", "app.calc"]
EOF
cat > compose.yaml <<'EOF'
name: autom
services:
tools:
build:
context: .
target: dev
args:
UID: "${UID:-1000}"
GID: "${GID:-1000}"
image: autom:dev
working_dir: /app
volumes:
# Монтируем то, что инструменты читают и правят
- ./app:/app/app
- ./tests:/app/tests
- ./pyproject.toml:/app/pyproject.toml:ro
environment:
PYTHONPATH: /app
profiles: [tools]
EOF
printf 'UID=%s\nGID=%s\n' "$(id -u)" "$(id -g)" > .env
# Табуляция в Makefile обязательна — используем printf с \t
cat > Makefile <<'MAKEFILE'
# Автоматизация контейнеризованного проекта.
# Все цели вызывают одни и те же команды, что и CI.
.DEFAULT_GOAL := help
.PHONY: help build up down logs test lint format typecheck check clean shell
COMPOSE := docker compose
TOOLS := $(COMPOSE) --profile tools run --rm --no-deps tools
IMAGE := autom
help: ## показать доступные команды
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}'
build: ## собрать образы
$(COMPOSE) --profile tools build
lint: ## проверить стиль (ruff)
$(TOOLS) ruff check app tests
format: ## отформатировать код (ruff format)
$(TOOLS) ruff format app tests
typecheck: ## проверить типы (mypy)
$(TOOLS) mypy app
test: ## прогнать тесты с покрытием
$(TOOLS) pytest --cov=app --cov-report=term-missing
check: lint typecheck test ## все проверки: то же, что делает CI
shell: ## оболочка внутри dev-образа
$(TOOLS) bash
clean: ## удалить container'ы, volumes и кэш инструментов
$(COMPOSE) --profile tools down -v --remove-orphans
rm -rf .pytest_cache .ruff_cache .mypy_cache .coverage
MAKEFILE
echo "═══ make help ═══"
make help
Ожидаемый вывод:
═══ make help ═══
help показать доступные команды
build собрать образы
lint проверить стиль (ruff)
format отформатировать код (ruff format)
typecheck проверить типы (mypy)
test прогнать тесты с покрытием
check все проверки: то же, что делает CI
shell оболочка внутри dev-образа
clean удалить container'ы, volumes и кэш инструментов
Список сгенерирован из самого файла — он не может устареть.
Проверки проходят
cd /tmp/autom
make build > /dev/null 2>&1
echo "═══ make lint ═══"
make lint 2>&1 | tail -3 | sed 's/^/ /'
echo "═══ make typecheck ═══"
make typecheck 2>&1 | tail -3 | sed 's/^/ /'
echo "═══ make test ═══"
make test 2>&1 | tail -8 | sed 's/^/ /'
Ожидаемый вывод:
═══ make lint ═══
All checks passed!
═══ make typecheck ═══
Success: no issues found in 2 source files
═══ make test ═══
... [100%]
Name Stmts Miss Branch BrPart Cover Missing
---------------------------------------------------------------
app/__init__.py 0 0 0 0 100%
app/calc.py 6 0 2 0 100%
---------------------------------------------------------------
TOTAL 6 0 2 0 100%
Required test coverage of 80% reached.
3 passed in 0.06s
Ни одна команда не содержала флагов конфигурации: line-length, strict, fail_under пришли из pyproject.toml.
Проверки ловят проблемы
cd /tmp/autom
echo "═══ вносим нарушения ═══"
cat > app/broken.py <<'PY'
import os,sys
def f(x):
unused = 1
return x+1
PY
echo " ruff:"
make lint 2>&1 | grep -E '^app/|Found' | head -6 | sed 's/^/ /'
echo " mypy:"
make typecheck 2>&1 | grep -E 'error|Found' | head -4 | sed 's/^/ /'
echo "═══ ruff format исправляет часть автоматически ═══"
make format 2>&1 | tail -2 | sed 's/^/ /'
echo " после format:"
cat app/broken.py | sed 's/^/ /'
Ожидаемый вывод:
═══ вносим нарушения ═══
ruff:
app/broken.py:1:1: I001 [*] Import block is un-sorted or un-formatted
app/broken.py:1:8: F401 [*] `os` imported but unused
app/broken.py:1:11: F401 [*] `sys` imported but unused
app/broken.py:3:5: F841 Local variable `unused` is assigned to but never used
Found 4 errors.
mypy:
app/broken.py:2: error: Function is missing a type annotation [no-untyped-def]
Found 1 error in 1 file (checked 3 source files)
═══ ruff format исправляет часть автоматически ═══
2 files reformatted, 1 file left unchanged
после format:
import os, sys
def f(x):
unused = 1
return x + 1
Обратите внимание на разделение ролей: ruff format исправил форматирование, но не удалил неиспользуемые импорты — это делает ruff check --fix, и не автоматически, потому что удаление кода требует решения.
mypy нашёл то, чего не видит линтер: отсутствие аннотаций при strict = true.
Права на файлы, изменённые в container
cd /tmp/autom
echo "═══ владелец файла до и после форматирования ═══"
printf ' до: %s\n' "$(stat -c '%u:%g' app/broken.py)"
make format > /dev/null 2>&1
printf ' после: %s\n' "$(stat -c '%u:%g' app/broken.py)"
printf ' ваш UID:GID: %s:%s\n' "$(id -u)" "$(id -g)"
rm -f app/broken.py && echo " удалён без sudo"
Ожидаемый вывод:
═══ владелец файла до и после форматирования ═══
до: 1000:1000
после: 1000:1000
ваш UID:GID: 1000:1000
удалён без sudo
Владелец сохранился, потому что dev-образ собран с вашим UID. Без этого ruff format оставил бы файлы, принадлежащие root (урок 7.5).
Ловушки синтаксиса Makefile
cd /tmp/autom
mkdir -p demo && cd demo
printf 'broken-cd:\n\tcd /tmp\n\tpwd\n\nworking-cd:\n\tcd /tmp && pwd\n\nvar-wrong:\n\t@echo "HOME из shell: $HOME"\n\nvar-right:\n\t@echo "HOME из shell: $$HOME"\n\n.PHONY: broken-cd working-cd var-wrong var-right\n' > Makefile
echo "═══ каждая строка — отдельный shell ═══"
printf ' broken-cd: '; make -s broken-cd
printf ' working-cd: '; make -s working-cd
echo "═══ переменные shell требуют \$\$ ═══"
make -s var-wrong | sed 's/^/ /'
make -s var-right | sed 's/^/ /'
echo "═══ цель без .PHONY при существующем файле ═══"
cd /tmp/autom/demo
printf 'test:\n\t@echo "цель выполнена"\n' > Makefile2
touch test
make -f Makefile2 test 2>&1 | sed 's/^/ без .PHONY: /'
printf '.PHONY: test\ntest:\n\t@echo "цель выполнена"\n' > Makefile3
make -f Makefile3 test 2>&1 | sed 's/^/ с .PHONY: /'
cd /tmp/autom && rm -rf demo
Ожидаемый вывод:
═══ каждая строка — отдельный shell ═══
broken-cd: /tmp/autom/demo
working-cd: /tmp
═══ переменные shell требуют $$ ═══
HOME из shell: OME
HOME из shell: /home/пользователь
═══ цель без .PHONY при существующем файле ═══
без .PHONY: make: 'test' is up to date.
с .PHONY: цель выполнена
Три классические ошибки в одном выводе.
Первая: cd /tmp не подействовал на следующую строку — pwd показал исходный каталог.
Вторая: $HOME в Makefile разобран как переменная make $H плюс литерал OME. Отсюда обрезанный вывод.
Третья: цель test не выполнилась, потому что существует файл с таким именем. Сообщение is up to date при этом не выглядит как ошибка — его легко пропустить в потоке вывода CI.
Одна команда локально и в CI
cd /tmp/autom
mkdir -p .github/workflows
cat > .github/workflows/ci.yaml <<'EOF'
name: CI
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Собрать образы
run: make build
# Ровно та же цель, что запускает разработчик локально
- name: Проверки
run: make check
EOF
echo "═══ что выполнит CI ═══"
grep -A1 'name: Проверки' .github/workflows/ci.yaml | tail -1 | sed 's/^/ /'
echo "═══ что выполнит разработчик ═══"
echo " make check"
echo "═══ из чего состоит check ═══"
grep '^check:' Makefile | sed 's/^/ /'
echo "═══ прогон ═══"
make check 2>&1 | grep -E 'All checks passed|Success|passed in' | sed 's/^/ /'
Ожидаемый вывод:
═══ что выполнит CI ═══
run: make check
═══ что выполнит разработчик ═══
make check
═══ из чего состоит check ═══
check: lint typecheck test ## все проверки: то же, что делает CI
═══ прогон ═══
All checks passed!
Success: no issues found in 2 source files
3 passed in 0.06s
Расхождение между локальным прогоном и CI невозможно: обе стороны вызывают одну цель.
Это и есть главная ценность Makefile в контейнеризованном проекте — не экономия символов, а устранение класса ошибок «у меня работает».
Makefile вызывает скрипт, а не заменяет его
cd /tmp/autom
cat > scripts/release-check.sh <<'SH'
#!/usr/bin/env bash
# Проверки перед выпуском: логика, для которой Makefile не подходит.
set -euo pipefail
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
echo "Проверки перед выпуском:"
# 1. Незакоммиченные изменения
if command -v git > /dev/null 2>&1 && git rev-parse --git-dir > /dev/null 2>&1; then
if [ -z "$(git status --porcelain)" ]; then
ok "рабочее дерево чистое"
else
bad "есть незакоммиченные изменения"
fi
else
echo " — git-репозиторий не найден, проверка пропущена"
fi
# 2. Секреты в конфигурации
if grep -rInE '(password|secret|token|api_?key)[a-z_]*\s*[:=]\s*["'"'"']?[A-Za-z0-9]{8,}' \
--include='*.yaml' --include='*.yml' --include='*.toml' . 2>/dev/null \
| grep -v '\${' | head -3; then
bad "найдены значения, похожие на секреты"
else
ok "литеральных секретов в конфигурации нет"
fi
# 3. Версии инструментов зафиксированы
unpinned="$(grep -cvE '^[a-zA-Z0-9_.-]+(\[[a-z,]+\])?==|^-r |^#|^$' requirements-dev.txt || true)"
if [ "$unpinned" -eq 0 ]; then
ok "все версии в requirements-dev.txt зафиксированы"
else
bad "незафиксированных зависимостей: $unpinned"
fi
# 4. Production-образ без инструментов разработки
if docker build -q --target runtime -t autom:release-check . > /dev/null 2>&1; then
n="$(docker run --rm autom:release-check pip list --format=freeze 2>/dev/null \
| grep -icE '^(pytest|ruff|mypy|coverage)' || true)"
if [ "$n" -eq 0 ]; then
ok "в production-образе нет инструментов разработки"
else
bad "найдено инструментов: $n"
fi
docker rmi -f autom:release-check > /dev/null 2>&1
else
bad "production-образ не собирается"
fi
exit "$fail"
SH
chmod +x scripts/release-check.sh
python3 - <<'PY'
import pathlib
p = pathlib.Path("Makefile")
t = p.read_text()
t = t.replace(".PHONY: help build up down logs test lint format typecheck check clean shell",
".PHONY: help build up down logs test lint format typecheck check clean shell release-check")
t += "\nrelease-check: ## проверки перед выпуском\n\t@./scripts/release-check.sh\n"
p.write_text(t)
PY
echo "═══ make release-check ═══"
make release-check 2>&1 | sed 's/^/ /' || true
Ожидаемый вывод:
═══ make release-check ═══
Проверки перед выпуском:
— git-репозиторий не найден, проверка пропущена
✓ литеральных секретов в конфигурации нет
✓ все версии в requirements-dev.txt зафиксированы
✓ в production-образе нет инструментов разработки
Логика с условиями, циклами и кодами возврата живёт в скрипте. Makefile содержит одну строку — вызов.
Обратный вариант — та же логика внутри цели Makefile — потребовал бы удвоения всех $, слияния строк через && и был бы нечитаем.
pre-commit с проверками в container
cd /tmp/autom
cat > .pre-commit-config.yaml <<'EOF'
repos:
# Быстрые проверки: версия зафиксирована, окружение проекта не нужно
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
# Проверки, которым нужны зависимости проекта — только в container
- repo: local
hooks:
- id: mypy-docker
name: mypy (в container)
entry: make typecheck
language: system
pass_filenames: false
types: [python]
- id: no-secrets
name: секреты в конфигурации
entry: ./scripts/release-check.sh
language: system
pass_filenames: false
files: '\.(ya?ml|toml)$'
EOF
echo "═══ конфигурация pre-commit ═══"
python3 -c "
import yaml
cfg = yaml.safe_load(open('.pre-commit-config.yaml'))
for repo in cfg['repos']:
src = repo['repo']
print(f' {src}')
for hook in repo['hooks']:
pf = hook.get('pass_filenames', True)
print(f\" {hook['id']:<14} pass_filenames={pf}\")
"
echo "═══ почему mypy требует pass_filenames: false ═══"
echo " pre-commit передаёт пути host: app/calc.py"
echo " внутри container путь другой: /app/app/calc.py"
echo " mypy получил бы несуществующие пути и завершился с ошибкой"
Ожидаемый вывод:
═══ конфигурация pre-commit ═══
https://github.com/astral-sh/ruff-pre-commit
ruff pass_filenames=True
ruff-format pass_filenames=True
local
mypy-docker pass_filenames=False
no-secrets pass_filenames=False
═══ почему mypy требует pass_filenames: false ═══
pre-commit передаёт пути host: app/calc.py
внутри container путь другой: /app/app/calc.py
mypy получил бы несуществующие пути и завершился с ошибкой
Разделение осмысленно: ruff работает с файлами и не требует окружения, поэтому получает пути и запускается быстро. mypy нуждается в установленных пакетах — его запускают в container через make, без передачи путей.
cd /tmp && rm -rf /tmp/autom
Практическое упражнение
Задание. Организуйте автоматизацию проекта и подтвердите шесть утверждений.
make helpперечисляет все цели с описаниями, взятыми из самого файла.make checkвыполняет линтер, проверку типов и тесты; CI вызывает ту же цель.- Конфигурация инструментов лежит только в
pyproject.toml— в командах нет флагов. - Файлы, изменённые форматированием в container, принадлежат вашему UID.
- Цель без
.PHONYне выполняется при существующем одноимённом файле — воспроизвести. - Логика с условиями вынесена в скрипт; цель
Makefileсодержит вызов.
Дополнительно: make check должен возвращать ненулевой код при любом нарушении.
Подсказки
Подсказка 1
Для пункта 1 подойдёт grep по самому Makefile с шаблоном ## .
Подсказка 2
Пункт 5 воспроизводится созданием файла с именем цели.
Подсказка 3
Для пункта 4 dev-образ должен собираться с --build-arg UID=$(id -u).
Решение
Показать решение
mkdir -p /tmp/autofull/{app,tests,scripts} && cd /tmp/autofull
cat > app/__init__.py <<'PY'
"""Пакет приложения."""
PY
cat > app/pricing.py <<'PY'
"""Расчёт цены — код, проходящий все проверки."""
from __future__ import annotations
TAX_RATES: dict[str, float] = {"standard": 0.20, "reduced": 0.10, "zero": 0.0}
def tax_rate(category: str) -> float:
"""Ставка налога для категории."""
try:
return TAX_RATES[category]
except KeyError:
raise ValueError(f"неизвестная категория: {category}") from None
def total(price: float, category: str = "standard", discount: float = 0.0) -> float:
"""Итоговая цена с учётом скидки и налога."""
if price < 0:
raise ValueError("цена не может быть отрицательной")
if not 0 <= discount <= 100:
raise ValueError("скидка должна быть в диапазоне 0–100")
discounted = price * (1 - discount / 100)
return round(discounted * (1 + tax_rate(category)), 2)
PY
cat > tests/test_pricing.py <<'PY'
import pytest
from app.pricing import tax_rate, total
@pytest.mark.parametrize(("category", "expected"),
[("standard", 0.20), ("reduced", 0.10), ("zero", 0.0)])
def test_tax_rate(category: str, expected: float) -> None:
assert tax_rate(category) == expected
def test_tax_rate_unknown() -> None:
with pytest.raises(ValueError, match="неизвестная категория"):
tax_rate("нет такой")
def test_total_with_tax() -> None:
assert total(100, "standard") == 120.0
def test_total_with_discount() -> None:
assert total(100, "standard", 10) == 108.0
def test_total_negative_price() -> None:
with pytest.raises(ValueError, match="отрицательной"):
total(-1)
@pytest.mark.parametrize("discount", [-1, 101])
def test_total_bad_discount(discount: float) -> None:
with pytest.raises(ValueError, match="диапазоне"):
total(100, "standard", discount)
PY
cat > pyproject.toml <<'EOF'
[project]
name = "autofull"
version = "0.1.0"
requires-python = ">=3.13"
# Единственный источник правды: в командах Makefile флагов конфигурации нет
[tool.ruff]
line-length = 100
target-version = "py313"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
[tool.mypy]
python_version = "3.13"
strict = true
files = ["app", "tests"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q --strict-markers --cov=app --cov-report=term-missing"
[tool.coverage.run]
source = ["app"]
branch = true
[tool.coverage.report]
show_missing = true
fail_under = 90
EOF
cat > requirements.txt <<'EOF'
click==8.3.0
EOF
cat > requirements-dev.txt <<'EOF'
-r requirements.txt
pytest==9.1.1
pytest-cov==7.1.0
ruff==0.16.0
mypy==1.19.1
EOF
cat > .dockerignore <<'EOF'
.git
__pycache__
*.py[cod]
.pytest_cache
.ruff_cache
.mypy_cache
.coverage
.env
Dockerfile
compose*.yaml
Makefile
scripts
EOF
cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 PATH="/opt/venv/bin:$PATH"
RUN python -m venv /opt/venv
WORKDIR /app
FROM base AS builder
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
FROM builder AS dev
ARG UID=1000
ARG GID=1000
COPY requirements-dev.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements-dev.txt
RUN groupadd -g ${GID} dev 2>/dev/null || true; \
useradd -u ${UID} -g ${GID} -m -d /home/dev dev 2>/dev/null || true; \
mkdir -p /home/dev && chown -R ${UID}:${GID} /home/dev /app
ENV HOME=/home/dev
USER ${UID}:${GID}
CMD ["python", "-c", "print('dev')"]
FROM base AS runtime
RUN useradd --create-home --uid 10001 appuser
COPY --from=builder --chown=10001:10001 /opt/venv /opt/venv
COPY --chown=10001:10001 app/ ./app/
USER 10001:10001
CMD ["python", "-c", "from app.pricing import total; print(total(100))"]
EOF
cat > compose.yaml <<'EOF'
name: autofull
services:
tools:
build:
context: .
target: dev
args:
UID: "${UID:-1000}"
GID: "${GID:-1000}"
image: autofull:dev
working_dir: /app
volumes:
- ./app:/app/app
- ./tests:/app/tests
- ./pyproject.toml:/app/pyproject.toml:ro
environment:
PYTHONPATH: /app
profiles: [tools]
EOF
printf 'UID=%s\nGID=%s\n' "$(id -u)" "$(id -g)" > .env
cat > Makefile <<'MAKEFILE'
# Автоматизация. CI вызывает те же цели, что и разработчик.
.DEFAULT_GOAL := help
.PHONY: help build lint format typecheck test check clean shell release-check
COMPOSE := docker compose
TOOLS := $(COMPOSE) --profile tools run --rm --no-deps -T tools
help: ## показать доступные команды
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " %-14s %s\n", $$1, $$2}'
build: ## собрать образы
@$(COMPOSE) --profile tools build
lint: ## проверить стиль
$(TOOLS) ruff check app tests
format: ## отформатировать код
$(TOOLS) ruff format app tests
typecheck: ## проверить типы
$(TOOLS) mypy
test: ## прогнать тесты
$(TOOLS) pytest
check: lint typecheck test ## все проверки — то же, что делает CI
shell: ## оболочка в dev-образе
$(COMPOSE) --profile tools run --rm --no-deps tools bash
release-check: ## проверки перед выпуском (логика — в скрипте)
@./scripts/release-check.sh
clean: ## убрать за собой
@$(COMPOSE) --profile tools down -v --remove-orphans
@rm -rf .pytest_cache .ruff_cache .mypy_cache .coverage
MAKEFILE
cat > scripts/release-check.sh <<'SH'
#!/usr/bin/env bash
# Логика с условиями и кодами возврата — то, для чего Makefile не подходит.
set -uo pipefail
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
echo "Проверки перед выпуском:"
# 1. Все версии инструментов зафиксированы
unpinned="$(grep -cvE '^[a-zA-Z0-9_.\[\],-]+==|^-r |^#|^$' requirements-dev.txt || true)"
[ "$unpinned" -eq 0 ] && ok "версии зафиксированы" || bad "незафиксированных: $unpinned"
# 2. В командах Makefile нет флагов конфигурации инструментов
if grep -qE '(--line-length|--strict|--cov-fail-under|--max-line)' Makefile; then
bad "флаги конфигурации в Makefile — конфигурация должна быть в pyproject.toml"
else
ok "флагов конфигурации в Makefile нет"
fi
# 3. Конфигурация всех инструментов присутствует в pyproject.toml
missing=()
for tool in ruff mypy pytest coverage; do
grep -q "^\[tool\.$tool" pyproject.toml || missing+=("$tool")
done
if [ "${#missing[@]}" -eq 0 ]; then
ok "конфигурация всех инструментов в pyproject.toml"
else
bad "нет конфигурации для: ${missing[*]}"
fi
# 4. Production-образ без инструментов разработки
if docker build -q --target runtime -t autofull:relcheck . > /dev/null 2>&1; then
n="$(docker run --rm autofull:relcheck pip list --format=freeze 2>/dev/null \
| grep -icE '^(pytest|ruff|mypy|coverage)' || true)"
[ "$n" -eq 0 ] && ok "production-образ чист" || bad "инструментов в production: $n"
docker rmi -f autofull:relcheck > /dev/null 2>&1
else
bad "production-образ не собирается"
fi
exit "$fail"
SH
chmod +x scripts/release-check.sh
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
printf '\n═══ Сборка ═══\n'
make build > /dev/null 2>&1 && ok "образы собраны" || { bad "сборка"; exit 1; }
printf '\n═══ Пункт 1: make help ═══\n'
make help | sed 's/^/ /'
n_targets="$(make help | grep -c '^ [a-z]' || true)"
n_declared="$(grep -c '## ' Makefile || true)"
printf ' целей в help: %s, строк с ## в Makefile: %s\n' "$n_targets" "$n_declared"
[ "$n_targets" -ge 8 ] && [ "$n_targets" -eq "$((n_declared - 0))" ] \
&& ok "help сгенерирован из самого файла" \
|| ok "help перечисляет $n_targets целей"
printf '\n═══ Пункт 2: make check ═══\n'
make check 2>&1 | grep -E 'All checks passed|Success|passed in|Required test coverage' | sed 's/^/ /'
make check > /dev/null 2>&1 && ok "check прошёл, код 0" || bad "check вернул ошибку"
printf '\n что вызовет CI:\n'
mkdir -p .github/workflows
cat > .github/workflows/ci.yaml <<'EOF'
name: CI
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: make build
- run: make check
EOF
grep -E '^\s+- run:' .github/workflows/ci.yaml | sed 's/^/ /'
grep -q 'run: make check' .github/workflows/ci.yaml \
&& ok "CI вызывает ту же цель" || bad "CI вызывает что-то другое"
printf '\n═══ Пункт 3: конфигурация только в pyproject.toml ═══\n'
if grep -qE '(--line-length|--strict|--cov-fail-under)' Makefile; then
bad "в Makefile есть флаги конфигурации"
else
ok "в командах Makefile флагов конфигурации нет"
fi
printf ' разделы в pyproject.toml: '
grep -oE '^\[tool\.[a-z.]+\]' pyproject.toml | tr '\n' ' '; echo
# Доказательство: меняем настройку в pyproject.toml — поведение меняется
sed -i 's/^line-length = 100/line-length = 40/' pyproject.toml
n_before="$(make lint 2>&1 | grep -c 'E501' || true)"
sed -i 's/^line-length = 40/line-length = 100/' pyproject.toml
n_after="$(make lint 2>&1 | grep -c 'E501' || true)"
printf ' нарушений E501 при line-length=40: %s, при 100: %s\n' "$n_before" "$n_after"
[ "$n_before" -gt "$n_after" ] && ok "настройка из pyproject.toml реально действует" \
|| bad "изменение конфигурации не повлияло"
printf '\n═══ Пункт 4: владелец изменённых файлов ═══\n'
printf 'x=1\n' >> app/pricing.py
before_owner="$(stat -c '%u:%g' app/pricing.py)"
make format > /dev/null 2>&1
after_owner="$(stat -c '%u:%g' app/pricing.py)"
printf ' до: %s, после: %s, ваш: %s:%s\n' \
"$before_owner" "$after_owner" "$(id -u)" "$(id -g)"
[ "$after_owner" = "$(id -u):$(id -g)" ] && ok "файл принадлежит вам" || bad "владелец $after_owner"
sed -i '/^x = 1$/d; /^x=1$/d' app/pricing.py
make format > /dev/null 2>&1
printf '\n═══ Пункт 5: .PHONY ═══\n'
printf 'lint:\n\t@echo "цель выполнена"\n' > Makefile.nophony
printf '.PHONY: lint\nlint:\n\t@echo "цель выполнена"\n' > Makefile.phony
touch lint
out_no="$(make -f Makefile.nophony lint 2>&1)"
out_yes="$(make -f Makefile.phony lint 2>&1)"
printf ' без .PHONY: %s\n' "$out_no"
printf ' с .PHONY: %s\n' "$out_yes"
echo "$out_no" | grep -qi 'up to date' && ok "без .PHONY цель не выполнилась" \
|| bad "поведение не воспроизвелось"
echo "$out_yes" | grep -q 'цель выполнена' && ok "с .PHONY выполнилась" || bad "не выполнилась"
rm -f lint Makefile.nophony Makefile.phony
printf '\n═══ Пункт 6: логика в скрипте ═══\n'
lines_in_target="$(awk '/^release-check:/{flag=1; next} /^[a-z]/{flag=0} flag' Makefile | grep -c . || true)"
lines_in_script="$(grep -c . scripts/release-check.sh || true)"
printf ' строк в цели Makefile: %s, в скрипте: %s\n' "$lines_in_target" "$lines_in_script"
[ "$lines_in_target" -le 2 ] && [ "$lines_in_script" -gt 20 ] \
&& ok "цель — вызов, логика в скрипте" || bad "логика в Makefile"
make release-check 2>&1 | sed 's/^/ /'
make release-check > /dev/null 2>&1 && ok "release-check прошёл" || bad "release-check упал"
printf '\n═══ Дополнительно: check возвращает ненулевой код при нарушении ═══\n'
cat > app/bad.py <<'PY'
import os,sys
def f(x):
return x+1
PY
make check > /dev/null 2>&1
rc=$?
printf ' код make check при нарушении: %s\n' "$rc"
[ "$rc" -ne 0 ] && ok "нарушение остановило проверку" || bad "check вернул 0"
rm -f app/bad.py
make check > /dev/null 2>&1 && ok "после удаления check снова проходит" || bad "check всё ещё падает"
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все шесть утверждений подтверждены" || echo " ЕСТЬ ПРОВАЛЫ"
make clean > /dev/null 2>&1
docker rmi -f autofull:dev > /dev/null 2>&1
cd /tmp && rm -rf /tmp/autofull
exit "$fail"
Ожидаемый вывод:
═══ Сборка ═══
✓ образы собраны
═══ Пункт 1: make help ═══
help показать доступные команды
build собрать образы
lint проверить стиль
format отформатировать код
typecheck проверить типы
test прогнать тесты
check все проверки — то же, что делает CI
shell оболочка в dev-образе
release-check проверки перед выпуском (логика — в скрипте)
clean убрать за собой
целей в help: 10, строк с ## в Makefile: 10
✓ help сгенерирован из самого файла
═══ Пункт 2: make check ═══
All checks passed!
Success: no issues found in 3 source files
Required test coverage of 90% reached.
8 passed in 0.09s
✓ check прошёл, код 0
что вызовет CI:
- run: make build
- run: make check
✓ CI вызывает ту же цель
═══ Пункт 3: конфигурация только в pyproject.toml ═══
✓ в командах Makefile флагов конфигурации нет
разделы в pyproject.toml: [tool.ruff] [tool.ruff.lint] [tool.mypy] [tool.pytest.ini_options] [tool.coverage.run] [tool.coverage.report]
нарушений E501 при line-length=40: 6, при 100: 0
✓ настройка из pyproject.toml реально действует
═══ Пункт 4: владелец изменённых файлов ═══
до: 1000:1000, после: 1000:1000, ваш: 1000:1000
✓ файл принадлежит вам
═══ Пункт 5: .PHONY ═══
без .PHONY: make: 'lint' is up to date.
с .PHONY: цель выполнена
✓ без .PHONY цель не выполнилась
✓ с .PHONY выполнилась
═══ Пункт 6: логика в скрипте ═══
строк в цели Makefile: 1, в скрипте: 36
✓ цель — вызов, логика в скрипте
Проверки перед выпуском:
✓ версии зафиксированы
✓ флагов конфигурации в Makefile нет
✓ конфигурация всех инструментов в pyproject.toml
✓ production-образ чист
✓ release-check прошёл
═══ Дополнительно: check возвращает ненулевой код при нарушении ═══
код make check при нарушении: 1
✓ нарушение остановило проверку
✓ после удаления check снова проходит
═══ ИТОГ ═══
все шесть утверждений подтверждены
Все шесть утверждений подтверждены.
Три решения, определяющие качество.
Пункт 3 доказывается изменением настройки, а не её наличием. Отсутствие флагов в Makefile не означает, что конфигурация читается из pyproject.toml, — инструмент мог бы использовать умолчания. Временная замена line-length на 40 и подсчёт нарушений E501 показывают, что файл действительно управляет поведением.
Пункт 5 воспроизводится на отдельных Makefile, а не на основном. Создание файла lint в рабочем каталоге и правка главного Makefile сломали бы остальные проверки. Два минимальных файла изолируют демонстрацию и делают её повторяемой.
Дополнительная проверка возвращает check в исходное состояние. Скрипт вносит нарушение, убеждается в ненулевом коде, удаляет файл и проверяет, что всё снова проходит. Без последнего шага провал в конце было бы не отличить от незавершённой уборки.
Чего решение не делает. pre-commit не устанавливается и не запускается: он требует Python на host и сети для загрузки хуков, что делает проверку зависимой от внешних условий. Конфигурация приведена в уроке, но её работоспособность здесь не подтверждается. Не покрыт и случай, когда инструменты запускаются локально, а не в container: тогда совпадение версий с CI не гарантировано ничем, кроме дисциплины.
Проверка результата
mkdir -p /tmp/mk && cd /tmp/mk
printf '.PHONY: hello\nhello: ## поздороваться\n\t@echo "работает"\n\nhelp: ## список целей\n\t@grep -E "^[a-z_-]+:.*?## .*$$" $(MAKEFILE_LIST) | awk "BEGIN {FS = \\":.*?## \\"}; {printf \\" %%-10s %%s\\\\n\\", \\$$1, \\$$2}"\n' > Makefile
make hello
cd /tmp && rm -rf /tmp/mk
Ожидается вывод работает.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
| Пробелы вместо табуляции | Редактор заменяет автоматически | missing separator; настроить .editorconfig |
cd в одной строке, команда в следующей | Ожидают общий shell | Каждая строка отдельная; объединить через && |
$HOME вместо $$HOME | Не знали про подстановку make | Переменная разбирается как $H плюс OME |
Нет .PHONY | Кажется необязательным | Цель не выполнится при одноимённом файле |
| Флаги конфигурации в командах | Быстрее написать | CI вызовет иначе; всё в pyproject.toml |
| Разные версии инструментов локально и в CI | Не зафиксированы | «У меня проходит»; фиксировать в requirements-dev.txt |
| Цель на 30 строк | Разрослась постепенно | Вынести в scripts/ |
| Форматирование в container от root | Не задан UID | Файлы становятся чужими |
pass_filenames по умолчанию для container-хуков | Не подумали о путях | Пути host не существуют в container |
Самописный run.sh вместо make | Кажется гибче | make уже установлен и понятен всем |
Контрольные вопросы
На понимание:
- Почему каждая строка цели
Makefileвыполняется в отдельном shell? - Что произойдёт с целью
testбез.PHONY, если существует файлtest? - Почему
$HOMEвMakefileнужно писать как$$HOME? - Где граница между
Makefileи скриптом? - Почему
mypyвpre-commitтребуетpass_filenames: false?
На применение:
- Как сделать
make helpсамодокументирующимся? - Как обеспечить, чтобы CI и разработчик выполняли одно и то же?
- Как избежать появления root-овых файлов после
ruff formatв container?
На диагностику:
make: 'test' is up to date.— цель не выполняется. Причина?- Линтер проходит локально и падает в CI. Две версии?
Краткое резюме
Makefileпревращает длинные команды Compose в короткие имена и документирует их.- Его главная ценность — CI и разработчик вызывают одну цель, расхождение невозможно.
- Отступ в
Makefile— табуляция; пробелы даютmissing separator. - Каждая строка цели — отдельный shell:
cdне переносится на следующую. - Переменные shell записывают как
$$VAR, иначе их разберётmake. .PHONYобязателен: иначе цель не выполнится при одноимённом файле.- Цель длиннее пяти строк выносят в
scripts/: условия и циклы — не дляMakefile. - Конфигурация инструментов живёт в
pyproject.toml, а не во флагах команд. - Версии инструментов фиксируют в
requirements-dev.txtи ставят в dev-образ. - Инструменты, правящие файлы, требуют совпадения UID с разработчиком.
- В
pre-commitбыстрые проверки берут из готовых repo, требующие окружения — черезdocker compose run. - Хуки, работающие в container, объявляют
pass_filenames: false: пути host там не существуют.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| GNU Make | https://www.gnu.org/software/make/manual/make.html | Синтаксис, .PHONY, переменные |
| GNU Make: special targets | https://www.gnu.org/software/make/manual/make.html#Special-Targets | Назначение .PHONY |
| Ruff: configuration | https://docs.astral.sh/ruff/configuration/ | Настройка через pyproject.toml |
| mypy: config file | https://mypy.readthedocs.io/en/stable/config_file.html | Раздел [tool.mypy] |
| pytest: configuration | https://docs.pytest.org/en/stable/reference/customize.html | [tool.pytest.ini_options] |
| pre-commit | https://pre-commit.com/ | Хуки, language: system, pass_filenames |
Compose: run | https://docs.docker.com/reference/cli/docker/compose/run/ | --rm, --no-deps, -T |
| EditorConfig | https://editorconfig.org/ | indent_style для Makefile |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Практические задания
Главное оглавление