Ruff + форматирование: как внедрить единый стиль в проект без “войны” и сломанных CI
Покажем, как настроить Ruff так, чтобы линтинг и автозамены работали согласованно с форматированием, и как мягко мигрировать существующий код. Будут примеры конфигурации, стратегии исключений и как не увеличить время проверок в CI.
Содержание
Ruff + форматирование: как внедрить единый стиль в проект без “войны” и сломанных CI
В большинстве Python-проектов стиль начинает жить собственной жизнью. Сначала это «пара замечаний» от линтера, затем появляются авто-фиксы, потом кто-то вручную начинает форматировать код в IDE, и постепенно возникает хроническая проблема: один инструмент «выигрывает» у другого. В итоге в PR копятся циклы вида:
- Ruff сообщает о нарушениях
- CI заваливается
- разработчик запускает авто-фикс
- но форматирование меняется снова (или наоборот)
- PR превращается в битву за формат, а не за смысл
Эта статья — практический разбор того, как настроить Ruff так, чтобы линтинг, автозамены и форматирование работали согласованно, и как мигрировать существующий код без «войны» и без роста времени проверок в CI.
Что именно ломается в реальных проектах
Перед настройкой полезно понять, где обычно появляется конфликт.
Две разные сущности: lint и format
Ruff в первую очередь — статический анализ (lint). Но в современных версиях он также умеет форматировать (через правила форматтера/код-стайла) и делать авто-фиксы. Проблема в том, что форматирование может:
- конкурировать с Black/isort/yapf и т.п.
- менять структуру строк так, что повторно задеваются правила lint
- применяться не в том же режиме, что ожидает CI
Несогласованные конфиги в локальной среде и CI
Чаще всего CI заваливается не потому, что «Ruff сломан», а потому что:
- локально запускался
ruff check --fix, а в CI —ruff format --check(или наоборот) - конфиг берётся из другого файла (
pyproject.tomlvsruff.toml) - игнор-листы (
exclude,extend-exclude,per-file-ignores) отличаются - команды выполняются с разными наборами правил
Линтится больше файлов, чем нужно
Если миграция делается «в лоб», то можно легко разогнать CI время:
- Ruff запускается на всём репозитории, включая
docs,venv, генерируемые артефакты - включается слишком широкий набор правил форматирования
- авто-фиксы выполняются в CI, а затем ещё и форматирование прогоняется отдельно
Базовая концепция: один источник истины и одна точка применения
Если свести подход к формуле, то она выглядит так:
- Один конфиг (обычно
pyproject.toml) — для всех запусков Ruff. - Одна цепочка действий: сначала форматирование (если вы его используете), затем линт+фиксы, и в CI только проверки (или с очень аккуратными исключениями).
- Единый набор правил и одинаковые флаги локально/в CI.
- Мягкая миграция: постепенно расширять покрытие и ужесточать правила.
Дальше — конкретика.
Настройка Ruff: конфигурация под единый стиль
Минимальный рабочий pyproject.toml
Ниже — каркас, который хорошо подходит под цель: единый стиль, согласованные проверки и минимальная «война».
[tool.ruff]
target-version = "py311"
line-length = 88
src = ["src", "tests"]
# Важно: исключения делаем один раз и используем везде.
exclude = [
".git",
".venv",
"venv",
"build",
"dist",
".mypy_cache",
".ruff_cache",
".pytest_cache",
]
# Для сокращения шума в миграции можно начать с выборочного выбора правил.
# Но ниже пример ориентирован на достаточно полный набор.
lint.select = [
"E",
"F",
"I", # isort-like (через ruff)
"UP",
"B",
"SIM",
]
# Игнор-листы лучше держать узкими и объяснимыми.
lint.ignore = [
# пример: не ругать конкретный кейс, если он осознанный
"B008",
]
[tool.ruff.format]
# Если проект использует Ruff format, выбирайте параметры один раз.
# line-length будет согласован с [tool.ruff] line-length.
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
# Автофиксы: Ruff умеет их делать, но иногда нужно контролировать поведение.
# Включение/выключение отдельных опций — по месту и под проект.
Примечание: конкретные поля форматтера зависят от версии Ruff. Идея в том, что формат и lint должны ссылаться на один и тот же
pyproject.tomlи одни и те же параметры (в частностиline-length).
Локальная команда “приведи к стандарту”
Обычно удобно завести одну команду для разработчиков — чтобы она делала то же самое, что ожидает CI.
Хорошая практика: разделить «форматирование» и «фиксы» на один скрипт.
ruff format .
ruff check --fix .
Если у вас большой репозиторий, стоит добавить к ruff check ограничения по директориям (например, ruff check src tests), чтобы не тратить время на то, что не влияет на продукт.
Что важно для согласованности
- В CI не запускаем форматирование с “не теми опциями”. Форматтер должен быть тем же
ruff formatи тем жеpyproject.toml. - Авто-фиксы не должны запускаться после форматирования с другой логикой. Обычно порядок такой:
ruff format(привести к стилю)ruff check --fix(починить то, что может быть исправлено линтером)
- Если в проекте уже используется Black/isort: сначала определитесь, кто «владелец» форматирования. Иначе вы получите перетягивание каната. Ruff удобно сделать основным владельцем, а старые инструменты постепенно отключать.
Форматирование vs линтинг: как избежать цикла “туда-сюда”
Почему вообще возникает цикл
Если вы включили:
- Ruff форматирует (
ruff format) - Ruff линтит (
ruff check) и делает фиксы - при этом другой инструмент (Black/isort/IDE) также форматирует
— то изменения могут происходить в несовместимых представлениях. Пример: ruff format меняет переносы/кавычки/выравнивания, а следом ruff check --fix меняет импорты или отдельные конструкции, которые форматтер снова перекомпонует, либо наоборот.
Стратегия “одного форматтера”
Самый практичный подход:
- либо только
ruff format(если проект готов принять стиль Ruff), - либо оставить Black, но тогда Ruff format выключить (и тогда конфликтов будет меньше),
- либо удерживать форматирование в одном инструменте до завершения миграции.
В статье мы рассматриваем вариант, когда Ruff — владелец. Тогда в проекте следует:
- перестать использовать Black как обязательный шаг CI
- отключить isort (если Ruff уже покрывает сортировку импортов и фиксы)
Миграция существующего кода без “войны”
Проблема миграции обычно не в конфигурации как таковой, а в объёме изменений.
Если прямо сейчас включить максимально строгие правила, то вы получите пачку правок по всему репозиторию. Это плохо для:
- ревью (сложно отличить шум от изменений)
- стабильности CI (много единовременных правок)
- предсказуемости для разработчиков
Шаг 1: начать с “текущего состояния” (baseline)
Один из самых спокойных сценариев — сначала собрать картину: какие правила реально нарушены. Для этого запускают:
ruff check .
ruff format --check .
Далее вы фиксируете то, что безусловно должно быть исправлено форматтером:
ruff format .
Затем делаете авто-фиксы линтера:
ruff check --fix .
После этого CI должен стать более стабильным.
Шаг 2: использовать “мягкие” ограничения на миграцию
Если код большой и часть нарушений вы не хотите исправлять сразу, используйте точечные исключения.
Пер-строка/пер-файл через per-file-ignores
Например, часто проблемы возникают в тестах или в legacy-коде:
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = [
"S101", # assert в тестах (пример)
"F401", # неиспользуемые импорты в тестах иногда осознанны
]
"src/legacy/**/*.py" = [
"E501", # длина строк: пока не мигрировали
]
Важно: такие исключения должны быть временно. В противном случае «legacy-исключение» превращается в постоянную яму.
Расширенные исключения extend-exclude
Если есть директории, которые не должны проверяться вообще:
[tool.ruff]
extend-exclude = ["migrations", "scripts/generated"]
Это экономит время и снижает шум.
Шаг 3: поэтапное ужесточение
Вместо того чтобы сразу включить весь набор правил, можно:
- сначала покрыть только ошибки (
E,F) - затем добавить стиль (
I,UP,SIM, и т.п.) - потом — более спорные правила
Технически это делается через lint.select и/или постепенно расширяемые lint.ignore/per-file-ignores.
Как не увеличить время проверок в CI
Время CI — часто первый “больной вопрос” после внедрения линтера.
1) Ограничивайте область проверки
Самая простая оптимизация: не проверять всё подряд.
В конфиге уже был src = ["src", "tests"]. Это помогает, но в командах CI лучше явно указать директории:
ruff check src tests
ruff format --check src tests
2) Используйте кэш (по умолчанию Ruff его использует)
У Ruff есть --cache-dir (или стандартные механизмы кэша в .ruff_cache). В CI кэш стоит сохранять, если ваша система поддерживает артефакты/кэширование.
Пример подхода (на уровне концепции):
- кэшировать
.ruff_cache - и не очищать кэш при каждом прогоне
3) Не запускайте авто-фиксы в CI (или делайте это только в режиме “быстро починили”)
Рекомендация для стабильного процесса:
- CI должен проверять, а не “редактировать” код.
- авто-фиксы — на стороне разработчиков (локально) и/или в отдельном job/скрипте, но не как часть обязательного шага на каждый PR.
Тогда ваш CI не будет:
- тратить время на запись файлов
- менять рабочую копию
- скрывать источники расхождений
4) Сведите команды в один job и не дублируйте работу
Не нужно запускать несколько раз ruff check . и затем снова ruff check. Лучше сформировать один набор команд:
ruff format --check ...ruff check ...
Рецепт CI: проверка без “поломок” и с предсказуемым поведением
Ниже пример общей структуры для GitHub Actions. Подберите под вашу CI-платформу.
name: lint
on:
pull_request:
push:
branches: [ "main" ]
jobs:
ruff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install ruff
# Желательно кэшировать .ruff_cache, если CI позволяет.
# Здесь оставлено концептуально.
- name: Check formatting
run: ruff format --check src tests
- name: Check lint
run: ruff check src tests
Почему это “мягче”, чем кажется
ruff format --checkгарантирует, что форматтер в CI соответствует локальному.ruff checkпроверяет lint с учётом конфигурации.- Никаких
--fixв CI — значит, не будет “скрытых правок”.
Стратегии исключений, которые не убивают дисциплину
Исключения — нормальная часть жизни, особенно при миграции. Но они должны быть “управляемыми”.
Правило №1: исключайте на уровне директории/паттерна, а не на уровне каждой строки
Если вы начинаете добавлять # noqa: ... в сотнях мест — проект постепенно теряет смысл линтинга как системы, а не как генератора шума.
Вместо этого используйте:
per-file-ignoresextend-exclude- разделение на “legacy/новая кодовая база”
Правило №2: помечайте исключения как временные
Ruff не заставляет вас документировать причины, но вы можете делать это на уровне конфига (или во внутренней документации).
Например:
[tool.ruff.lint.per-file-ignores]
"src/legacy/**/*.py" = [
# TODO: убрать после миграции стиля и рефакторинга конструкций
"E501",
]
Это снижает риск, что исключение “навсегда”.
Правило №3: не отключайте правила целиком без причины
lint.ignore = ["E"] на старте миграции выглядит соблазнительно, но потом будет трудно возвращаться в нормальный режим. Лучше:
- временно ослабить набор
select - точечно исключить больные места
Частые подводные камни при внедрении Ruff форматирования
Подводный камень: расхождение версий инструмента
Ruff развивается быстро. Форматтер может менять детали форматирования между версиями. Поэтому:
- фиксируйте версию Ruff в зависимостях (например, в
requirements-dev.txtили через lock-файл) - используйте один и тот же инструмент и в локальной среде, и в CI
Подводный камень: разные Python-версии
target-version влияет на то, какие конструкции считаются “современными”. Убедитесь, что он соответствует реальному рантайму проекта.
Подводный камень: форматирование без проверки --check
Иногда команда форматирования запускается без проверки. В результате:
- локально всё выглядит нормально
- в CI формат не совпадает, потому что кто-то не прогнал
ruff format
Решение: CI обязательно должен включать ruff format --check.
Подводный камень: слишком “широкая” стартовая конфигурация
Начинать лучше с базовых категорий (E, F) и постепенно добавлять остальное. Полный набор сразу может привести к огромной первой волне коммитов.
Практический план внедрения за 3–5 итераций
Ниже сценарий, который на практике обычно работает для команд.
Итерация 1: диагностика и baseline
- Зафиксировать текущие нарушения:
ruff check .ruff format --check .
- Создать PR только с конфигурацией (и возможно — с массовыми фиксациями форматтера, если это допустимо).
Итерация 2: привести код к формату
ruff format .git commitотдельным коммитом (чтобы ревью было легче).
Итерация 3: авто-фиксы lint
ruff check --fix .- Коммит отдельно.
Итерация 4: закрепить дисциплину в CI
- Добавить
ruff format --checkиruff checkв CI. - Убедиться, что команды проверяют только нужные директории.
Итерация 5: ужесточение по правилам
- Постепенно расширить
lint.select. - Убрать часть
per-file-ignoresтам, где код уже мигрировал. - Следить за временем CI и областью проверки.
Когда стоит задуматься о обучающем материале
Если в вашей команде пока меньше практики по Ruff (или конфигурации выглядят как набор “магических” опций), полезно пройти структурированный материал. Например, курс **«ruff – для начинающих!»** может помочь быстрее разобраться в том, как устроены select, ignore, фиксы и форматирование — без необходимости методом проб и ошибок на реальном репозитории.
Выводы
Внедрение Ruff с форматированием — это не “включить одну кнопку”, а настройка режима взаимодействия инструментов: линтинга, авто-фиксов и форматтера. Чтобы не получить “войну” и сломанные CI:
- сделайте один источник истины в конфигурации (
pyproject.toml) - обеспечьте согласованную цепочку:
ruff format→ruff check(с фиксацией только локально/отдельно) - мигрируйте поэтапно, используя точечные исключения (
per-file-ignores,extend-exclude) - держите CI в режиме проверок, избегая
--fixна каждом PR - оптимизируйте время: проверяйте только нужные директории и используйте кэш
Так вы получите единый стиль, предсказуемые PR и линтер, который помогает разработке, а не отвлекает от неё.
Если хотите, могу предложить пример готового pyproject.toml под ваш стек (Poetry/uv/pip, структура папок, версии Python, есть ли Black/isort и какие правила сейчас конфликтуют) — но для этого нужно знать текущие команды CI и конфигурации форматирования.
Комментарии
Пока нет комментариев