Молниеносный инкрементальный линтинг Python-кода

Линтинг кода бывает очень долгим, а в ситуациях наличия большого legacy‑проекта, который решили «причесать»,  линтинг может причинять боль и страдания разработчикам. В этой статье мы найдем решение, которое позволит без проблем линтить код с любого этапа разработки и делать это супер быстро и инкрементально!

085c5c1fdcebc2561d26c6f81a5bfc19.jpeg

Часто на проектах встречается проблема автоматического форматирования исходного кода для соответствия определенным стандартам стиля. Кроме того, хочется проверять свою разработку на наличие потенциальных проблем с безопасностью и общим качеством. Чтобы не тратить время на ручное ревью и форматирование кода разработчиком, возможно подключить так называемые линтеры — специальные пакеты для автоматизации вышеописанных проверок. Процесс таких проверок идет без выполнения исходного кода, поэтому он называется статическим анализом.

Линтеры интегрируются в 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. Вы можете их адаптировать под свой проект и цели. Всем спасибо за внимание и молниеносного линтинга!

© Habrahabr.ru