Всем привет! Меня зовут Юра, я Python-разработчик в Точке. В статье я покажу, как написать шаблон с линтером для Gitlab CI, чтобы при старте нового проекта (или уже запущенного) было легко добавить линтер в пайплайны.
Мотивация
В своих проектах я активно использую flakeheaven (современная обёртка над flake8) с большим количеством плагинов — это позволяет избежать бесполезных дискуссий на код-ревью, поддерживать порядок в проектах и предотвращать некоторые ошибки и уязвимости. За решение этих проблем я плачу временем на прекоммит-хуки и задачи в CI.
Когда плагинов становится слишком много, проверка занимает больше времени и, соответственно, сильнее вырывает из потока. Поэтому я обратил внимание на ruff — реализацию правил из flake8 и нескольких самых популярных плагинов на Rust. С помощью ruff проверка объёмных проектов проходит очень быстро: например, в моём проекте запуск flakeheaven занимает минуту, а ruff — около секунды.
В Точке довольно большое Python-сообщество, которое занимается не только поддержкой внутренних библиотек, но и инструментов для разработчиков, таких как шаблоны для сборки и анализа кода. Поэтому, когда я настроил ruff в своём проекте, я решил сделать шаблон для Gitlab CI, который можно было бы использовать в других проектах.
Gitlab CI
У нас используется плагин для Gitlab CI, который позволяет запускать джобы в кластере k8s, поэтому сделаем проект с образом Docker и в шаблоне будем использовать его.
Дисклеймер
Как у любой приличной компании, у нас внутри развёрнут docker registry, но для простоты дальше в коде я вместо него буду использовать dockerhub и прятать его под переменной окружения $CI_REGISTRY
.
В Dockerfile возьмём последнюю версию python, установим линтер и загрузим его в хранилище:
# Dockerfile
FROM python:3.12-slim
RUN python3 -m pip install ruff==0.1.0
Для запуска ruff требуется версия python 3.7, но для анализа используются значение requires-python
в pyproject.toml
и аргумент запуска --target-version
, поэтому мы используем самый свежий образ python. Для версионирования используем следующую схему: когда обновляем Dockerfile
с новой версией линтера, помечаем коммит тегом, совпадающим с версией ruff. В gitlab-ci при появлении нового тега будем загружать новую версию образа в хранилище
# gitlab-ci.yaml
variables:
CI_REGISTRY: ...
stages:
- build
build:
stage: build
rules:
- if: $CI_COMMIT_TAG
# before_script: docker login
script:
- docker build -t "ruff:$CI_COMMIT_TAG"
- docker tag "ruff:$CI_COMMIT_TAG" "$CI_REGISTRY/ruff-template/ruff:$CI_COMMIT_TAG"
- docker push "$CI_REGISTRY/ruff-template/ruff:$CI_COMMIT_TAG"
Теперь, когда у нас появился образ с линтером в хранилище, пришло время написать шаблон, в котором мы будем его запускать:
# linters.yaml
.lint-ruff:
variables:
RUFF_VERSION: latest
RUFF_SOURCE: .
CI_REGISTRY: ...
image: $CI_REGISTRY/ruff-template/ruff:$RUFF_VERSION
script:
- |
set -ex
RUFF_ARGS=""
if [[ -n $RUFF_TARGET_VERSION ]]; then
RUFF_ARGS="$RUFF_ARGS --target-version $RUFF_TARGET_VERSION"
fi
echo "ruff args: $RUFF_ARGS"
echo "ruff source: $RUFF_SOURCE"
ruff $RUFF_ARGS $RUFF_SOURCE
С помощью переменной RUFF_TARGET_VERSION
пользователь сможет указывать целевую версию языка для запуска. Аналогично можно добавить поддержку остальных параметров запуска, если появится необходимость. Теперь, когда у нас есть образ и шаблон, мы можем его использовать в своих проектах:
# .gitlab-ci.yaml
include:
- project: ruff-template
file: linters.yaml
stages:
- test
lint-ruff:
extends:
.lint-ruff
stage: test
variables:
RUFF_TARGET_VERSION: py312
У меня в проекте используется Python 3.12, поэтому я указываю переменную окружения, чтобы правильно работали проверки импортов из future
и новые фишки языка.
Настройка
По умолчанию ruff проверяет код на соответствие стандарту PEP8 и очевидных проблем вроде неиспользованных импортов с помощью правил pyflakes и pycodestyle. В отличие от flake8, ruff не поддерживает 3rd party плагины, поэтому все дополнительные правила реализованы в проекте. На данный момент реализовано более 700 правил, поэтому с высокой долей вероятности ваш любимый плагин уже в их числе. Например:
DTZ
не даёт создать объектtimestamp
без таймзоныPT
помогает поддерживать одинаковый стиль в тестахTRY
нужен для соблюдения лучших практик в работе с исключениями
Полный список правил можно посмотреть в документации.
Ниже готовый конфиг без "вкусовщины": его можно скопировать в pyproject.toml
и начать использовать ruff на полную катушку.
[tool.ruff]
src = ["src", "tests"]
target-version = "py311"
line-length = 90
extend-select = [
"C90", # mccabe
"N", # pep8-naming
"UP", # pyupgrade
"S", # bandit
"BLE", # flake8-blind-except
"B", # bugbear
"C4", # comprehensions
"DTZ", # datetimez
"EM", # error-messages
"FA", # future-annotations
"ISC", # implicit string concat
"PIE", # flake8-pie
"PT", # flake8-pytest-style
"SLF", # flake8-self
"SIM", # flake8-simplify
"ARG", # flake8-unused-argument
"ERA", # eradicate commented out code
"TRY", # tryceratops
]
[tool.ruff.extend-per-file-ignores]
"migrations/**.py" = ["E501"] # line length
"tests/**.py" = ["S101"] # use of `assert`
Итоги
Создание шаблона — это объёмная задача, которую можно разбить на несколько этапов, и на каждом из них можно извлечь пользу:
Добавление линтера упрощает поддержку единого стиля в проекте и предотвращает часть ошибок.
Отдельный образ можно использовать в нескольких проектах, и задача на линт будет занимать меньше времени.
Шаблон для запуска образа позволяет экономить время на поддержке для нескольких команд.
Я надеюсь, что благодаря статье вы увидели, что общие инструменты для разработчиков — это не привилегия мегакорпораций, и их можно начинать использовать, когда у вас становится больше одного репозитория.