Главная/Development workflow/Урок

10.5. Автоматизация

Цели

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

  • написать Makefile, который остаётся понятным через полгода;
  • объяснить, почему Makefile удобнее набора скриптов и где его граница;
  • запускать ruff, mypy и pytest одинаково локально и в CI;
  • держать конфигурацию инструментов в одном месте;
  • встроить проверки в pre-commit в контейнеризованном проекте;
  • понимать, когда контейнеризация инструментов мешает больше, чем помогает.

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

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

ТерминОбъяснение
цельИменованная команда Makefile: make test
.PHONYЦель, не создающая файла с таким именем
pre-commitИнструмент запуска проверок перед коммитом
единый источник правдыОдно место, откуда конфигурация берётся всеми
pyproject.tomlСтандартный файл конфигурации Python-проекта

Теория

Зачем Makefile

В контейнеризованном проекте команды длинные:

bash
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
Каждая строка — отдельный shellcd в одной строке не влияет на следующую
Переменные $ нужно удваивать$$HOME для переменной shell
Цель без .PHONYНе выполнится, если существует файл с таким именем
make останавливается на первой ошибкеОбычно желаемое поведение

Вторая строка объясняет типичную ошибку:

makefile
broken:
	cd build          # этот cd не действует на следующую строку
	./run.sh

works:
	cd build && ./run.sh

.PHONY обязателен

makefile
.PHONY: test build clean

Без него make test не выполнится, если в каталоге есть файл или каталог test: make решит, что цель уже собрана.

Каталог tests/ при цели test не конфликтует, а при цели tests — конфликтует. Проще объявлять .PHONY для всех служебных целей сразу.

Единый источник правды для инструментов

Конфигурация линтеров должна лежать в одном месте и читаться одинаково локально, в container и в CI.

toml
# 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"
АнтипаттернПочему плохо
Флаги в командной строке MakefileCI вызывает иначе — результат расходится
Отдельный 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 в контейнеризованном проекте

yaml
# .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

Самодокументирование окупается на второй неделе проекта:

makefile
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:

ini
[Makefile]
indent_style = tab

Как make определяет, нужно ли выполнять цель

По умолчанию make сравнивает время изменения цели и её зависимостей. Цель, не являющаяся файлом, всегда считается устаревшей — но только если объявлена в .PHONY.

Без .PHONY и при наличии одноимённого файла make выведет:

text
make: 'test' is up to date.

и ничего не выполнит. Ошибка тихая и сбивает с толку.


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

Makefile для контейнеризованного проекта

bash
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

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

text
═══ make help ═══
  help         показать доступные команды
  build        собрать образы
  lint         проверить стиль (ruff)
  format       отформатировать код (ruff format)
  typecheck    проверить типы (mypy)
  test         прогнать тесты с покрытием
  check        все проверки: то же, что делает CI
  shell        оболочка внутри dev-образа
  clean        удалить container'ы, volumes и кэш инструментов

Список сгенерирован из самого файла — он не может устареть.

Проверки проходят

bash
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/^/  /'

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

text
═══ 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.

Проверки ловят проблемы

bash
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/^/    /'

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

text
═══ вносим нарушения ═══
  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

bash
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"

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

text
═══ владелец файла до и после форматирования ═══
  до:    1000:1000
  после: 1000:1000
  ваш UID:GID: 1000:1000
  удалён без sudo

Владелец сохранился, потому что dev-образ собран с вашим UID. Без этого ruff format оставил бы файлы, принадлежащие root (урок 7.5).

Ловушки синтаксиса Makefile

bash
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

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

text
═══ каждая строка — отдельный 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

bash
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/^/  /'

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

text
═══ что выполнит 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 вызывает скрипт, а не заменяет его

bash
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

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

text
═══ make release-check ═══
  Проверки перед выпуском:
    — git-репозиторий не найден, проверка пропущена
    ✓ литеральных секретов в конфигурации нет
    ✓ все версии в requirements-dev.txt зафиксированы
    ✓ в production-образе нет инструментов разработки

Логика с условиями, циклами и кодами возврата живёт в скрипте. Makefile содержит одну строку — вызов.

Обратный вариант — та же логика внутри цели Makefile — потребовал бы удвоения всех $, слияния строк через && и был бы нечитаем.

pre-commit с проверками в container

bash
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 получил бы несуществующие пути и завершился с ошибкой"

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

text
═══ конфигурация 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, без передачи путей.

bash
cd /tmp && rm -rf /tmp/autom

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

Задание. Организуйте автоматизацию проекта и подтвердите шесть утверждений.

  1. make help перечисляет все цели с описаниями, взятыми из самого файла.
  2. make check выполняет линтер, проверку типов и тесты; CI вызывает ту же цель.
  3. Конфигурация инструментов лежит только в pyproject.toml — в командах нет флагов.
  4. Файлы, изменённые форматированием в container, принадлежат вашему UID.
  5. Цель без .PHONY не выполняется при существующем одноимённом файле — воспроизвести.
  6. Логика с условиями вынесена в скрипт; цель Makefile содержит вызов.

Дополнительно: make check должен возвращать ненулевой код при любом нарушении.

Подсказки

Подсказка 1

Для пункта 1 подойдёт grep по самому Makefile с шаблоном ## .

Подсказка 2

Пункт 5 воспроизводится созданием файла с именем цели.

Подсказка 3

Для пункта 4 dev-образ должен собираться с --build-arg UID=$(id -u).

Решение

Показать решение
bash
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"

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

text
═══ Сборка ═══
  ✓ образы собраны

═══ Пункт 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 не гарантировано ничем, кроме дисциплины.

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

bash
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 уже установлен и понятен всем

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

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

  1. Почему каждая строка цели Makefile выполняется в отдельном shell?
  2. Что произойдёт с целью test без .PHONY, если существует файл test?
  3. Почему $HOME в Makefile нужно писать как $$HOME?
  4. Где граница между Makefile и скриптом?
  5. Почему mypy в pre-commit требует pass_filenames: false?

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

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

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

  1. make: 'test' is up to date. — цель не выполняется. Причина?
  2. Линтер проходит локально и падает в CI. Две версии?

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

  1. Makefile превращает длинные команды Compose в короткие имена и документирует их.
  2. Его главная ценность — CI и разработчик вызывают одну цель, расхождение невозможно.
  3. Отступ в Makefile — табуляция; пробелы дают missing separator.
  4. Каждая строка цели — отдельный shell: cd не переносится на следующую.
  5. Переменные shell записывают как $$VAR, иначе их разберёт make.
  6. .PHONY обязателен: иначе цель не выполнится при одноимённом файле.
  7. Цель длиннее пяти строк выносят в scripts/: условия и циклы — не для Makefile.
  8. Конфигурация инструментов живёт в pyproject.toml, а не во флагах команд.
  9. Версии инструментов фиксируют в requirements-dev.txt и ставят в dev-образ.
  10. Инструменты, правящие файлы, требуют совпадения UID с разработчиком.
  11. В pre-commit быстрые проверки берут из готовых repo, требующие окружения — через docker compose run.
  12. Хуки, работающие в container, объявляют pass_filenames: false: пути host там не существуют.

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

ИсточникСсылкаЧто подтверждает
GNU Makehttps://www.gnu.org/software/make/manual/make.htmlСинтаксис, .PHONY, переменные
GNU Make: special targetshttps://www.gnu.org/software/make/manual/make.html#Special-TargetsНазначение .PHONY
Ruff: configurationhttps://docs.astral.sh/ruff/configuration/Настройка через pyproject.toml
mypy: config filehttps://mypy.readthedocs.io/en/stable/config_file.htmlРаздел [tool.mypy]
pytest: configurationhttps://docs.pytest.org/en/stable/reference/customize.html[tool.pytest.ini_options]
pre-commithttps://pre-commit.com/Хуки, language: system, pass_filenames
Compose: runhttps://docs.docker.com/reference/cli/docker/compose/run/--rm, --no-deps, -T
EditorConfighttps://editorconfig.org/indent_style для Makefile

Навигация

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

Markdown на GitHub ↗