Запуск и поддержка проекта на Ruff в команде: формат “как в CI”, а не “как договорились”
Как правильно выстроить конфигурацию Ruff (линтинг, форматирование, автозамены) и сделать так, чтобы одинаковые правила гарантировались в локальной сборке и на CI без ручного шаманства.
Содержание
Запуск и поддержка проекта на Ruff в команде: формат “как в CI”, а не “как договорились”
Ruff за последние годы стал де-факто стандартом для линтинга и форматирования в Python-проектах: он быстрый, конфигурируется прозрачно, умеет автозамены и ориентируется на реальные практики (а не на “ручной стиль как договорились”). Но именно в командной работе чаще всего вскрывается проблема: локально разработчики “подгоняют” код под правила, а на CI внезапно всплывают расхождения. Итог — лишние итерации PR, споры о конфигурации и ощущение, что Ruff “то работает, то нет”.
Хорошая новость: это лечится правильной схемой “как в CI”. Нужно добиться трёх эффектов:
- Один и тот же набор правил применяется и локально, и в пайплайне.
- Результаты одинаковые, независимо от того, кто запускает (Windows/macOS/Linux) и как именно.
- Автозамены и форматирование происходят предсказуемо: либо разработчик запускает ровно те команды, что стоят в CI, либо они выполняются автоматически в pre-commit/IDE/тех же целях.
Ниже — практичный разбор, как выстроить Ruff-конфигурацию, чтобы команда жила по одним законам. Плюс разберём типичные ошибки, которые ломают “одинаковость”.
Архитектура: что именно делает Ruff в проекте
Ruff — это набор инструментов под капотом. В зависимости от режима он может:
- Lint: проверять стиль и качество (правила из наборов rule codes, например
E,F,B,I,UPи т.д.). - Format: форматировать код, приближаясь к “черновику” конкретного стиля (схема ближе к
black, но есть нюансы). - Fix / Auto-fix: предлагать и применять исправления части линтерных проблем.
Важный момент для команды: линтинг, форматирование и исправления — это не одно действие, и их нужно связать общей конфигурацией и одинаковыми параметрами запуска.
Конфиг “как в CI”: один источник истины
Ключ к стабильности — хранить конфигурацию в репозитории и запускать Ruff одними и теми же флагами.
Где хранить конфиг
Самый практичный вариант — один файл конфигурации в корне проекта:
pyproject.toml(часто используется в Python-проектах)- реже
ruff.toml
Почти всегда выбирают pyproject.toml, потому что он уже используется для инструментов (mypy/pytest/black/coverage).
Базовый скелет pyproject.toml
Пример структуры (адаптируйте под свой репозиторий):
[tool.ruff]
line-length = 100
target-version = "py311"
src = ["src", "tests"]
[tool.ruff.lint]
# Включаем базовые категории правил (пример).
select = ["E", "F", "W", "I", "UP", "B"]
# Игнорируем конкретные правила, если они объективно мешают проекту.
ignore = ["E501"]
# Если хотите, чтобы Ruff учитывал типовые ограничения:
# (в зависимости от вашей стратегии, может быть полезно)
fixable = ["ALL"]
unfixable = []
[tool.ruff.lint.isort]
# Если используете isort через Ruff
known-first-party = ["your_package"]
# known-third-party = ["requests", "numpy"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
Этот пример иллюстрирует принципы, а не “универсальную идеальность”. В реальном проекте обычно начинают с “скопировать существующий стиль”, а затем постепенно ужесточают правила.
Линтинг и правила: “похожее локально” — не подходит
Самая частая причина рассинхронизации: локально разработчики запускают Ruff без тех же опций, что в CI. Например:
- локально:
ruff check . - в CI:
ruff check . --select E,F --fixили с другимtarget-version - или: различаются
src/targetиexclude - или: в одном месте действует
.ruff.toml, а в другом — иной файл/флаги.
Что сделать, чтобы не расходилось
- Все правила — в
pyproject.toml, а не только во флагах команды. - В CI и локально использовать одну и ту же команду (или команды-обёртки, которые повторяют флаги).
- Явно задать
srcи исключения, чтобы Ruff сканировал одинаковые директории.
Например, добавьте в pyproject.toml:
[tool.ruff]
src = ["src"]
exclude = [
".git",
".venv",
"build",
"dist",
"*.egg-info",
]
Если вы используете tests, добавьте их в src, иначе CI может проверять иначе (если у вас в CI стоит ruff check ., это покроет всё, включая tests, а локально вы могли запускать только src).
Инкрементальная стратегия: не “сломать всё сразу”
Если проект большой и сейчас стиль неоднородный, “включить всё” сразу может быть болезненно: будут сотни/тысячи ошибок, и никто не сможет быстро довести код до нормы.
Честная стратегия:
- Включите набор базовых правил (обычно
E,F,W, плюс то, что реально исправимо). - Сначала настройте
fixable, чтобы команда могла применять автозамены. - Затем постепенно расширяйте
select/ужесточайте.
Например, стартовать можно так:
[tool.ruff.lint]
select = ["E", "F", "W"]
fixable = ["ALL"]
А затем добавить I (import sorting), UP (обновления), B (bugbear) и т.д.
Форматирование: Ruff Format не должен “ехать” относительно Black
Важно понимать роль ruff format. Если в проекте параллельно используется black, нужно определиться, кто “истина”.
В идеале Ruff Format используется как единый форматтер. Если вы оставляете black, то Ruff будет использоваться только для lint/check/fix. Но если вы хотите единообразие “как в CI”, то:
- либо CI запускает
ruff format, - либо CI запускает
black, - и локально разработчик делает то же самое.
Рекомендованный сценарий: “один форматтер”
Если вы выбираете Ruff Format:
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
line-ending = "auto"
И затем в CI выполняете:
ruff format --check .
А локально —:
ruff format .
Частая ошибка: не фиксировать формат автоматически
Если CI только проверяет формат (--check), но не форматирует, то разработчик обязан держать код в форме руками. Это снова ведёт к “договорились”.
Лучшее решение для команды — связать форматирование с автозамена/фиксацией, например через pre-commit (ниже) или через единые команды в Makefile/justfile.
Исправления (fix): как сделать “автозамены” стабильными
ruff check --fix применяется для некоторых классов нарушений. Важно:
- не все правила автоматически чинятся;
- комбинации
select/ignoreвлияют на набор применённых исправлений; - форматирование (
ruff format) и исправления (ruff check --fix) лучше разделять по логике: fix меняет код под линтерные правила, format приводит к стилю.
Отдельный контроль для команды
Хорошая практика: выделить явно 2 шага:
- применить автозамены линтера
- прогнать форматтер
Пример команд:
ruff check --fixruff format
В CI эти шаги либо выполняются в “режиме проверки”, либо выполняются при наличии stage-брейка (часто не дают коммитить изменение автоматически, а требуют, чтобы разработчик прислал исправления).
Стандарт команд: Makefile как “контракт”
Чтобы “как в CI”, нужен не только одинаковый конфиг, но и одинаковые команды. Команда разработчиков должна быть одинаковой, иначе вы снова получите расхождение.
На практике удобнее сделать небольшую оболочку — через Makefile или justfile.
Пример Makefile
.PHONY: lint lint-fix format format-check ci
lint:
ruff check .
lint-fix:
ruff check . --fix
format:
ruff format .
format-check:
ruff format --check .
ci: lint format-check
Тогда локально разработчик запускает:
make lint-fix format- и затем
make ciдля проверки.
В CI используется ровно make ci.
Это не “магия”, а банальное устранение расхождения: одинаковые флаги и одинаковая последовательность действий.
pre-commit: гарантировать правила до CI (а не после)
Если вы хотите, чтобы код никогда не попадал в PR с очевидными нарушениями, pre-commit помогает сильно. Он запускает команды перед коммитом, снижая шум.
Пример .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.4
hooks:
- id: ruff
args: ["check"]
- id: ruff-format
Пара замечаний:
- Фиксируйте
rev— иначе при обновлении Ruff поведение может измениться. - Если у вас строгая стратегия автозамены, можно добавлять отдельный hook под
ruff check --fix(но осторожно: это меняет файлы при коммите).
Нужно ли pre-commit, если CI уже есть?
Не взаимоисключающее. CI — это “последняя граница”. pre-commit — “раннее предупреждение”. В командной работе pre-commit часто снижает число циклов “почему CI ругается”.
CI: как именно добиться равенства с локальной сборкой
Суть требования “как в CI” сводится к тому, что CI должен запускать те же команды, что и вы рекомендуете разработчикам.
Пример GitHub Actions
name: CI
on:
push:
pull_request:
jobs:
lint-format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install ruff
- name: Lint
run: ruff check .
- name: Format check
run: ruff format --check .
Проблема, которая часто встречается: разработчики ставят ruff версии “последняя” (или из lock’а), а CI — другой версии. Тогда правила могут отличаться (особенно в новых релизах).
Решение: зафиксировать версию Ruff. Например, в requirements-dev.txt или через pip install ruff==0.x.y и обновлять осознанно.
Версионирование и согласованность: почему “одинаково” не значит “одно и то же”
Команда может иметь одинаковый pyproject.toml, но всё равно получить разные результаты из-за:
- Разных версий Ruff (семантика правил и автозамены могут меняться).
- Разных версий Python (влияет
target-versionи некоторые правила). - Разных платформ (реже, но бывает через line endings, импорт сортировки, пути).
- Разных параметров запуска (сканируется разный набор файлов).
Практика: фиксируйте версию
Пример добавления в requirements-dev.txt:
ruff==0.6.4
И в CI, и локально устанавливайте из этого списка (или через pip-tools/uv/pdm — зависит от стека).
Практика: фиксируйте target-version
В pyproject.toml:
[tool.ruff]
target-version = "py311"
Это снижает разъезды между командами, когда один разработчик запускает под Python 3.10, а другой — под 3.12, и Ruff начинает по-разному оценивать совместимость.
Стратегия обновления правил без хаоса
Ruff хорош тем, что правила можно включать/выключать конфигом. Но если делать это “по желанию” каждого, получите хаос.
Рекомендованный процесс в команде
- Вводите новые правила через PR, где:
- фиксируется одна версия Ruff,
- выполняется
ruff check --fix, - выполняется
ruff format, - и PR содержит только необходимые изменения.
- В
pyproject.tomlиспользуйте комментарии рядом сselect/ignore(в разумных пределах), чтобы не терять смысл. - Отдельно согласуйте политику по “дорогим” правилам: например, правила, требующие исправления импорта (
I), могут поменять много строк.
Типичный подводный камень: ignore слишком широко
ignore = ["E501"] — нормальная локальная история. Но если вы начинаете игнорировать большие группы (например, ignore = ["ALL"] “пока не разберёмся”), проект деградирует. Лучше:
- игнорировать точечно,
- использовать постепенное включение,
- и по возможности доводить код до целевого состояния.
Полезные команды для локальной отладки
Когда вы настраиваете Ruff “как в CI”, полезно иметь набор диагностических команд.
Быстро понять, какие файлы проверяются
ruff check --show-files .
Узнать, какие правила срабатывают (и почему)
ruff check . --select E,F --output-format=concise
Применить автозамены локально
ruff check . --fix
ruff format .
Проверить формат в “точности как CI”
ruff format --check .
Если вы видите расхождение между локальным запуском и CI, почти всегда причина в одном из четырёх пунктов: другая версия Ruff, другой target-version, другая команда/флаги, другой набор файлов.
Набор “правил игры”: чему учить команду
Командный процесс — не менее важная часть, чем конфиг.
Минимальный “контракт разработчика” может быть таким:
- Перед коммитом — pre-commit (или минимум:
ruff check .иruff format --check .). - Если добавляете изменения — запускаете
ruff check --fix(если используете автозамены) и затемruff format. - CI не “другая реальность”, а проверка того, что вы сделали локально.
Если команда следит за контрактом, CI становится предсказуемым, а не карательным.
Как это выглядит в конфигурации “под проект”
Соберём пример более цельного pyproject.toml, который можно взять за основу и адаптировать:
[tool.ruff]
line-length = 100
target-version = "py311"
src = ["src", "tests"]
exclude = ["build", "dist", ".venv", ".git"]
[tool.ruff.lint]
# Начинаем с базового набора
select = ["E", "F", "W", "I", "UP", "B"]
ignore = []
fixable = ["ALL"]
# Опционально: чтобы правила не мешали переходному периоду
# unfixable = []
[tool.ruff.lint.isort]
known-first-party = ["your_package"]
[tool.ruff.format]
quote-style = "double"
И затем в CI всегда:
ruff check .ruff format --check .
А локально:
ruff check . --fixruff format .ruff format --check .(опционально как контроль)
Вывод: Ruff в команде — это дисциплина конфигурации и команд
Ruff хорошо справляется с задачей “единый стиль и единые правила”. Но в команде он становится предсказуемым только при выполнении двух условий:
- Единый источник правды: вся логика — в конфиге (
pyproject.toml), а не в случайных командах. - Единый контракт запуска: локальные команды должны совпадать с тем, что делает CI (вплоть до последовательности
check/fix/formatи до фиксированной версии Ruff).
Если вам нужно систематизировать подход — полезно пройти структурное введение, например по теме “ruff – для начинающих!” (/course/ruff-free). Это один из способов быстрее “собрать в голове” логику линтинга/форматирования/автозамен и перестать воспринимать настройку Ruff как набор разрозненных флагов.
Когда контракт соблюдён, CI перестаёт быть местом, где “ломается стиль”, и превращается в формальную проверку того, что правила действительно одинаковы для всех.
Комментарии
Пока нет комментариев