Доброго дня!

Меня зовут Соболев Андрей и сегодня я вам расскажу как мы приготовили .pre-commit hook на нашем проекте.

Вступление


Для начала пару слов, о том что такое в целом хуки (hooks) и для чего они могут быть нужны. Git «из коробки» предоставляет инструмент, который умеет запускать ваши скрипты при наступлении какого либо события (к примеру пуш на сервер и т.п.)

.pre-commit это удобная надстройка над дефолтным git pre-commit hook, которая запускает скрипты описанные в .pre-commit-config.yaml перед созданием коммита. В теории звучит просто, перейдем к практике.

Установка


Установим необходимые зависимости:

pre-commit
# основной пакет https://pre-commit.com/

autoflake
# для удаления неиспользуемых импортов (в нашем случае)
black
# форматируем код
pyupgrade
# приводим его к последней версии
reorder-python-imports
# делаем красивые импорты
yesqa
# удаляем неиспользуемые noqa комментарии (для линтеров)

# линтеры
flake8
flake8-annotations
flake8-annotations-coverage
flake8-bandit
flake8-broken-line
flake8-bugbear
flake8-builtins
flake8-commas
flake8-comprehensions
flake8-debugger
flake8-eradicate
flake8-executable
flake8-fixme
flake8-future-import
flake8-pyi
flake8-pytest
flake8-pytest-style
flake8-mutable
flake8-string-format
flake8-todo
flake8-unused-arguments

# тесты
pytest
pytest-django

Выскажу свое мнение по поводу flake-8 и линтеров в целом. Если у вас уже большой проект с кучей legacy кода, то можете линтеры смело удалять. Затраты которые будут потрачены на «приведение к идеалу», начальство не оценит. Линтеры ставим для новых (и небольших) проектов. Повторюсь, это лично мое мнение, никому его не навязываю.

Интеграция в среду


Заходим в корневой каталог среды разработки и выполняем следующие команды

$ pre-commit install
pre-commit installed at .git/hooks/pre-commit
$ pre-commit --version
pre-commit 2.4.0

Если .pre-commit будет ругаться на sqlite, то вам нужно будет его установить (к примеру $ yum install sqlite) и собрать python заново

Настройка файла .pre-commit-config.yaml


В корневом каталоге среды создаем файл .pre-commit-config.yaml

- repo: https://github.com/pre-commit/pre-commit-hooks
  rev: "v2.5.0"
  hooks:
    - id: check-merge-conflict
    - id: debug-statements

- repo: local

  hooks:
    - id: black
      name: black
      entry: black
      language: system
      types: [python]
      args: [--line-length=200, --target-version=py37]

    - id: autoflake
      name: autoflake
      entry: autoflake
      language: system
      types: [python]
      args: [--in-place, --remove-all-unused-imports, --remove-duplicate-keys]

    # -   id: flake8
    #     name: flake8
    #     entry: flake8
    #     language: system
    #     types: [python]
    #     args: [
    #         "--ignore=E203,W503,FI10,FI11,FI12,FI13,FI14,FI15,FI16,FI17,FI58,PT013",
    #         # black
    #             # E203 whitespace before ':'
    #             # W503 line break before binary operator
    #         # flake8-future-import
    #             # FI10 __future__ import "division" missing
    #             # FI11 __future__ import "absolute_import" missing
    #             # FI12 __future__ import "with_statement" missing
    #             # FI13 __future__ import "print_function" missing
    #             # FI14 __future__ import "unicode_literals" missing
    #             # FI15 __future__ import "generator_stop" missing
    #             # FI16 __future__ import "nested_scopes" missing
    #             # FI17 __future__ import "generators" missing
    #             # FI58 __future__ import "annotations" present
    #         # flake8-pytest-style
    #             # PT013 found incorrect import of pytest, use simple 'import pytest' instead
    #         "--max-line-length=110",
    #         "--per-file-ignores=tests/*.py:S101"
    #         # S101 Use of assert detected
    #     ]

    - id: pyupgrade
      name: pyupgrade
      entry: pyupgrade
      language: system
      types: [python]
      args: [--py37-plus]

    - id: reorder-python-imports
      name: reorder-python-imports
      entry: reorder-python-imports
      language: system
      types: [python]
      args: [--py37-plus]

    - id: yesqa
      name: yesqa
      entry: yesqa
      language: system
      types: [python]

    - id: tests
      name: Run tests
      entry: "bash tests.sh"
      language: system
      verbose: true


Тесты


Помимо проверки и форматирования кода мы будем выполнять тесты на этапе создания коммита. Для этого мы будем использовать pytest (https://docs.pytest.org/en/latest/) и настроим его для наших нужд.

В корневом каталоге нашей среды создадим файл pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = settings
python_files = tests.py test_*.py *_tests.py


Далее создадим папку tests и поместим туда следующие файлы
test_example_without_db.py, test_example_with_db.py

Для удобства настроим тесты таким образом, чтобы можно было использовать текущую базу данных (к примеру копию базы данных с боевого сервера), а не создавать каждый раз новую.

Простой тест test_example_without_db.py

def inc(x):
    return x + 1

def test_answer():
    assert inc(3) == 4

В простых тестах мы можем подключить например webbot и обходить узлы нашей системы, чтобы автоматизировать ручной труд тестировщика.

Тест с использованием базы данных test_example_with_db.py

import pytest
import settings
from chat.models import ChatRoom
from settings import POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD,                     POSTGRES_HOST, POSTGRES_PORT

@pytest.fixture(scope='session')
def django_db_setup():
    settings.DATABASES['default'] = {
        'ENGINE': 'django.db.backends.postgresql_psycopg2',
        'NAME': POSTGRES_DB,
        'USER': POSTGRES_USER,
        'PASSWORD': POSTGRES_PASSWORD,
        'HOST': POSTGRES_HOST,
        'PORT': POSTGRES_PORT,
    }   
    
    
@pytest.fixture
def db_access_without_rollback_and_truncate(request, django_db_setup, django_db_blocker):
    django_db_blocker.unblock()
    request.addfinalizer(django_db_blocker.restore)

def chat():
    return ChatRoom.objects.all().count()

@pytest.mark.django_db
def test_chat():
    assert chat() > 0

Пример довольно искусственный и создан исключительно для данной заметки, но тем не менее он позволяет нам обратиться к текущей базе данных, и провести сложные тесты, которые выходят за рамки ручного тестирования.

Подключаем тесты в .pre-commit


Чтобы подключить тесты нам потребуется shell script в корневом каталоге среды, который мы назовем tests.sh:

source ../../python38_env/bin/activate && python -m pytest -v tests

Его содержание весьма очевидное, но вы можете заметить что активация виртуального окружения явным образом прописана в коде. Это может быть неудобно, если ваша команда ведет разработку на разных рабочих станциях (к примеру кто развернул среду на локальной машине, а кто-то разрабатывает на сервере).

Можно решить эту проблему через переменные в .env

Пример реализации:

github.com/Sobolev5/starlette-vue-backend/blob/master/.env.example (обратите внимание на переменную ENV_ACTIVATE)

github.com/Sobolev5/starlette-vue-backend/blob/master/tests.sh (парсим ENV_ACTIVATE и активируем среду)

Создаем коммит


Теперь осталось создать коммит и посмотреть как это работает

$ git add .
$ git commit -m Sobolev:TestPreCommitHook
Check for merge conflicts................................................Passed
Debug Statements (Python)................................................Passed
black....................................................................Failed
- hook id: black
- files were modified by this hook

reformatted /var/www/file.py
All done!   
1 file reformatted, 2 files left unchanged.

autoflake................................................................Passed
pyupgrade................................................................Passed
reorder-python-imports...................................................Failed
- hook id: reorder-python-imports
- exit code: 1
- files were modified by this hook

Reordering imports in file.py

yesqa....................................................................Passed
Run tests................................................................Passed
- hook id: tests
- duration: 2.85s

tests/test_example_with_db.py::test_chat PASSED                          [ 66%]
tests/test_example_without_db.py::test_answer PASSED                     [100%]

Коммит теперь создается в «два этапа». На первом этапе хуки выполняют форматирование кода, поэтому после их работы нам нужно просто «повторить» команды.

Получается следующая последовательность.

$ git add .
$ git commit -m Sobolev:TestPreCommitHook
$ git add .
$ git commit -m Sobolev:TestPreCommitHook

На этом все, спасибо за внимание.

Дополнительные ссылки


> Полный список хуков