Молниеносный инкрементальный линтинг Python-кода
Линтинг кода бывает очень долгим, а в ситуациях наличия большого legacy‑проекта, который решили «причесать», линтинг может причинять боль и страдания разработчикам. В этой статье мы найдем решение, которое позволит без проблем линтить код с любого этапа разработки и делать это супер быстро и инкрементально!
Часто на проектах встречается проблема автоматического форматирования исходного кода для соответствия определенным стандартам стиля. Кроме того, хочется проверять свою разработку на наличие потенциальных проблем с безопасностью и общим качеством. Чтобы не тратить время на ручное ревью и форматирование кода разработчиком, возможно подключить так называемые линтеры — специальные пакеты для автоматизации вышеописанных проверок. Процесс таких проверок идет без выполнения исходного кода, поэтому он называется статическим анализом.
Линтеры интегрируются в CI/CD‑процессы (Continuous Integration, Continuous Delivery — непрерывная интеграция и доставка) и выполняются в pipeline (поток автоматической интеграции и доставки). Шаг за шагом стартуют разные типы проверок: собирается образ, запускаются линтеры, далее идут автоматические тесты и деплой приложения в целевую среду доставки.
Сложности с legacy-проектами
Если проект начинается с нуля, то внедрить статический анализ кода проще: код будет отформатирован и проверен с самого начала, с первого коммита. При таком подходе весь код приложения всегда соответствует стандартам проекта и проверен линтерами. Однако встречаются и проекты, на которых линтеры решили внедрить уже после начала разработки. Например, это был MVP (Minimum Viable Product — минимально жизнеспособный продукт), поэтому решили не тратить время на дополнительную вспомогательную работу. Тогда, если над проектом работает множество разработчиков, возникает проблема синхронизации правил оформления кода и требуется некая стандартизация в правилах.
Правила могут быть зафиксированы в настройках линтеров. После осуществления предварительных настроек линтеры добавляются к проекту, но появляется другая сложность — как быть с уже существующим, еще не отформатированным кодом при изменении части модуля? Если «прогнать» линтер по модулю, то будет затронут код, который не относится к задаче разработчика. Это может усложнить и замедлить процессы код‑ревью и доставки кода. Логичным видится решение проблемы путем форматирования только сделанных изменений, то есть отслеживать код инкремента. Для этого каким‑то образом необходимо получить эту разницу в коде и передать её на вход линтеру.
Так как у разработчиков в распоряжении всегда есть важный инструмент для работы с исходным кодом, а именно система контроля версий, то можно воспользоваться ей. git предоставляет подобную функциональность с помощью команды git diff, которая, согласно документации, позволяет получить разницу кода между коммитом и целевой веткой. Например, стандартный сценарий доставки кода следующий. Существует ветка master (или main), которая всегда доставляется до продакшн сервера. В эту ветку отправляется весь код, над которым работают разработчики в своих feature‑ветках. Значит, можно сопоставить версии кода из текущей feature‑ветки и целевой master‑ветки с получением искомой разницы. Именно эта разница и будет передаваться линтеру для проверки, что позволит отслеживать только измененный код и форматировать его. Подобный подход применим не ко всем типам линтеров.
Типы линтеров и сценарии их использования
Существует множество линтеров, нацеленных на решение определенных задач. Линтеры можно условно разделить на две группы: для автоматического форматирования и статического анализа. Линтеры, относящиеся к первой группе, исправляют оформление исходного кода согласно указанным в конфигурации стандартам. Линтеры из второй группы проверяют соответствие типов, наличие неиспользуемых импортов и переменных, оценивают качество кода и так далее.
Для инкрементального линтинга хорошо подходят линтеры‑форматировщики, так как они только форматируют код, поступающий к ним. То есть им можно передавать осмысленную (синтаксически корректную) часть кода и они её правильно обработают. Линтеры из второй группы не всегда способны правильно воспринять и интерпретировать лишь фрагменты кода без контекста их применения, так как они анализируют общую структуру, например, обнаруживают наличие неиспользуемых импортов модулей. Поэтому для такой группы линтеров инкрементальная проверка не применима в полной мере или не возможна. Однако допустимо передавать им список обновленных файлов для анализа, а не части затронутого изменениями кода.
При таком подходе (с передачей целиком файлов) может проверяться и legacy‑код (код, который ранее существовал в проекте), что не всегда является целью, потому что ревьювить такой код сложнее и появляются изменения, не относящиеся к задачам разработки. Тогда необходимо настроить конфигурацию линтера таким образом, чтобы степень строгости проверки была достаточной и в то же время оптимальной для конкретной ситуации и проекта. Исходя из этого, можно реализовать инкрементальный линтинг с гарантированным форматированием только измененного кода и опционально со статической проверкой файлов (возможно и с исправлением плохого кода), в которых были изменения.
Примеры линтеров из первой группы: Black, autopep8, YAPF, isort.
Примеры линтеров из второй группы: Pylint, Mypy, Flake8, Pyflakes.
Инструмент для инкрементального форматирования кода
Для решения вышеизложенной проблемы (с ней сталкивается едва ли не каждый разработчик) были предприняты попытки создания open‑source продукта под названием darken, который впоследствии переродился в проект Darker. Darker — линтер для инкрементального форматирования кода. Он основан на Black и даже использует его в своем исходном коде. Для обеспечения совместимости авторы Darker постоянно отслеживают изменения в Black и вносят соответствующие доработки для корректной работы с новой версией Black. Darker под капотом реализует все необходимые действия с git‑ом (использует git diff), находит измененный код и передает его Black для форматирования. Результат работы линтера записывается в исходные файлы, тем самым форматируются только обновленные блоки кода.
У Darker есть хорошая документация, которая позволит понять, как его настроить именно под нужды на вашем проекте. Так как Darker задействует git для получения информации о разнице кода можно задавать целевую ветку для сравнения. По умолчанию Darker использует последний коммит (HEAD — указатель на последний коммит в текущей ветке) в качестве отправной точки. Кроме того, есть возможность задать конкретные файлы для форматирования или отформатировать целиком весь проект. Чтобы попробовать Darker в действии достаточно его установить и запустить команду в корне репозитория с исходным кодом:
darker --diff .
В консоль будет выведена информация с демонстрацией инкремента изменения (diff) и предложением отформатировать конкретные строки кода. Исходный код при этом затронут не будет. Данная команда полезна для предварительного анализа перед запуском фактического форматирования.
Чтобы действительно отформатировать код достаточно запустить команду:
darker .
В консоль будет выведена информация о том какие файлы были затронуты обновлением.
Darker помимо своей основной функциональности интегрирован и с другими линтерами: flynt для обновления устаревшего стиля оформления строк на f‑строки, isort для сортировки импортов. Кроме того, Darker может работать с подключаемыми внешними линтерами. Список подходящих на момент написания статьи: Mypy, Pylint, Flake8, cov_to_lint.py (разработанный автором Darker).
Статический анализ кода
Для статического анализа кода в затронутых изменениями файлах рекомендую присмотреться к линтеру Ruff. Он разработан на языке Rust, включает в себя большое количество правил для анализа (более 700) и работает от 10 до 100 раз быстрее других линтеров, согласно заявлению его авторов. Он один способен заменить целый ряд линтеров: Flake8, isort, pydocstyle, yesqa, eradicate, pyupgrade, autoflake. Но специализированные инструменты для проверки типов (например, Mypy) Ruff в полной мере заменить не сможет.
Если вы хотите автоматизировать процесс линтинга еще больше, то это возможно. Ruff умеет исправлять fixable‑ошибки (исправляемые ошибки) в коде. Например, удалить неиспользуемые импорты или переменные. Полный список замечаний, которые можно исправить с помощью Ruff, доступен в документации. Все нужные правила возможно описать в едином файле pyproject.toml.
Пример конфигурации из документации
[tool.ruff]
# Enable the pycodestyle (`E`) and Pyflakes (`F`) rules by default.
# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or
# McCabe complexity (`C901`) by default.
select = ["E", "F"]
ignore = []
# Allow fix for all enabled rules (when `--fix`) is provided.
fixable = ["ALL"]
unfixable = []
# Exclude a variety of commonly ignored directories.
exclude = [
".bzr",
".direnv",
".eggs",
".git",
".git-rewrite",
".hg",
".mypy_cache",
".nox",
".pants.d",
".pytype",
".ruff_cache",
".svn",
".tox",
".venv",
"__pypackages__",
"_build",
"buck-out",
"build",
"dist",
"node_modules",
"venv",
]
per-file-ignores = {}
# Same as Black.
line-length = 88
# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
# Assume Python 3.8
target-version = "py38"
Благодаря тонкой настройке линтера можно добиться необходимого вам результата, что позволит снизить количество рутинных действий и упростить жизнь разработчика.
Универсальное решение
Хотелось бы иметь некое универсальное решение, обладающее преимуществами Darker и Ruff для инкрементального линтинга — быструю работу и простоту использования с гибкостью конфигурации. Darker предоставляет возможность передачи консольной команды для запуска линтера в качестве аргумента. Это позволяет попробовать запустить Ruff вместе с Darker:
darker --revision origin/main --diff --check --lint "ruff check --exit-zero" .
Ruff действительно работает только для измененных файлов и выводит предупреждения в соответствии с его настройками, но действует он не так быстро, как ожидалось согласно представлению о заявленной «молниеносной» работе. Такая команда при проведении эксперимента выполнялась 12 секунд в проекте с 42 тысячами строк кода. Причем сам Darker отработал и вывел результат за 2 секунды. Остальное время ушло на выполнение команды, которая указана в качестве значения аргумента ‑-lint. Если запустить данную команду отдельно, то получим результат за 70 миллисекунд (0.070 сек.), что в 142 раза быстрее!
Конкретные значения сильно зависят от контекста (объема исходного кода, количества изменений в нем и настроек линтера), но порядок величин понятен. Это значит, что если мы хотим по настоящему быстрого результата без раздражающего ожидания процесса линтинга, то Ruff необходимо запускать вне Darker. Да, в документации к Darker не заявлена поддержка Ruff, но попробовать стоило. Всё‑таки команда с запуском стороннего линтера удобна и доступна «из коробки».
Осталось решить вопрос запуска Ruff только для измененных файлов. Возможно разработать решение самостоятельно на базе команды git diff или попробовать найти что‑то готовое и подходящее. После недолгих поисков оказалось, что решение есть — git‑diff‑lint! Скрипт давно не обновлялся, но он минималистичен и работоспособен. Идея та же — скрипт получает относительные пути ко всем затронутым изменениями файлам и выводит их в консоль:
git diff --name-only --diff-filter=d $(git merge-base HEAD "origin/main") | grep -E "\.py$"
git diff --name-only --diff-filter=d — получение путей ко всем измененным файлам (кроме удаленных).
git merge‑base HEAD «origin/main» — получение общего предка между двумя коммитами (подробнее в документации git).
grep -E »\.py$» — фильтрация по расширению .py.
Вывод этой команды вполне можно передать на вход Ruff для последующего анализа:
ruff check $(git diff --name-only --diff-filter=d $(git merge-base HEAD "origin/main") | grep -E "\.py$")
Действительно, это работает. Линтер с ожидаемой скоростью обработал все измененные файлы и вывел результат в консоль.
Собираем всё вместе
Теперь осталось объединить все наработки вместе: взять Darker с его возможностью форматирования измененных строк кода и Ruff с его талантом быстро проводить статический анализ. Можно использовать следующую конструкцию для проверки кода (без внесения командой изменений в исходный код):
ruff check --exit-zero $(git diff --name-only --diff-filter=d $(git merge-base HEAD "origin/main") | grep -E "\.py$") && darker --revision origin/main --diff --check .
Данная команда выведет в консоль все ошибки статического анализа, включая предложения линтера отформатировать код. Опция ‑-exit‑zero позволит выполниться следующей команде в случае наличия ошибок, обнаруженных Ruff.
Как уже известно Ruff достаточно способен, чтобы попытаться улучшить плохой код. Для того, чтобы исправить найденные ошибки, можно запустить команду:
ruff --fix .
Но нужно быть осторожным и не переоценить его возможностей. Иногда изменения могут привести к критическим ошибкам в коде. Для того, чтобы этого избежать, необходимо тщательно настроить линтер и отключить те проверки, которые не требуются или потенциально могут привести к проблемам. Для конфигурации можно задать список исправляемых ошибок (fixable) и список неисправляемых ошибок (unfixable). Также в комплексном автоматизированном решении автоматические тесты способы решить эту задачу и выявить проблемы с кодом на раннем этапе. Вы же их пишете, правда?
Для автоматизации команд и удобства внедрения в повседневные процессы разработки и в CI/CD можно подготовить необходимые sh‑скрипты и использовать Makefile. В нем нужно описать требуемые таргеты с вызовами скриптов. Это позволит в одной команде запустить комплексный процесс анализа кода. Команды можно настроить нужным вам образом. В примере ниже идея в том, чтобы проверять форматирование кода и вызывать ошибку при наличии расхождений с правилами и в то же время статический анализ линтером Ruff не будет приводить к ошибкам даже при обнаруженных замечаниях.
Пример оформления таргетов в Makefile:
TARGET = origin/main
.PHONY: lint
lint: ## Статический анализ измененных файлов
# проверка качества кода
./git-diff-lint -x "ruff check --exit-zero" -b $(TARGET)
# проверка стиля оформления кода
darker --revision $(TARGET) --diff --check
.PHONY: fix
fix: ## Автоматическое исправление измененных файлов
# форматирование кода
darker --revision $(TARGET)
# попытка исправить обнаруженные недостатки кода
./git-diff-lint -x "ruff --fix --silent --exit-zero" -b $(TARGET)
Тогда всё, что нужно будет сделать разработчику до оформления очередного коммита, вызвать команду make fix, которая приведет к форматированию измененного кода с автоматическим исправлением некачественного кода в затронутых файлах. Причем команда выполнится довольно быстро.
Для запуска проверок в CI/CD‑процессах команду make lint или исходные команды под этим таргетом можно добавить в один из шагов pipeline до слияния веток. Тогда наличие ошибок будет препятствовать слиянию и сигнализировать разработчику, ответственному за ветку, разобраться в проблеме и устранить найденные недостатки. Впоследствии такой подход обеспечит инкрементальное улучшение качества кода с каждым очередным коммитом.
Примеры скриптов можно найти на моем GitHub. Вы можете их адаптировать под свой проект и цели. Всем спасибо за внимание и молниеносного линтинга!