Автоматизируем проверку кода или еще немного о pre-commit hook’ах

в 11:35, , рубрики: cross-platform, Git, github, pre-commit hook, python, Программирование, метки:

Думаю, нет нужды рассказывать хабрапользователю что такое Git / GitHub, pre-commit и как наносить ему hook справа. Перейдем сразу к делу.

В сети много примеров хуков, большинство из них на shell'ах, но ни один автор не уделил внимание одному важному моменту — хук приходится таскать из проекта в проект. На первый взгляд — ничего страшного. Но вдруг появляется необходимость внести изменения в хук, который уже живет в 20 проектах… Или внезапно нужно переносить разработку с Windows на Linux, а хук на PowerShell'е… Что делать? ??????? PROFIT

«Лучше так: 8 пирогов и одна свечка!»

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

  • выполнять проверку отправляемого в репозиторий кода на валидность (например: соответствие требованиям PEP8, наличие документации итд);
  • выполнять комплексную проверку проекта (юнит-тесты итд);
  • прерывать операцию commit'а в случае обнаружения ошибок и отображать подробный журнал для разбора полетов.

И выглядел приблизительно так:

python pre-commit.py --check pep8.py --test tests.py

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

pre-commit.py

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

Этими параметрами будем задавать основное поведение скрипта:

  • -c или --check [скрипт1… скриптN] — запуск скриптов проверки на валидность. Скрипт должен располагаться в том же каталоге, что и pre-commit.py. Иначе — нужно указать полный путь. Каждому скрипту будут «скармливаться» файлы из текущего коммита.
  • -t или --test [тест1… тестN] — запуск юнит-тестов и прочих скриптов, которым не требуются файлы текущего коммита. Тест должен располагаться в каталоге текущего проекта. Иначе — нужно указать полный путь.

Оба параметра будут необязательными (для возможности оставить только один тип проверки), но если не указать ни один из них, pre-commit.py завершит работу с кодом «1» (ошибка).

И добавим вспомогательные параметры (все необязательные):

  • -e или --exec путь_к_интерпретатору — полный путь (с именем файла) к интерпретатору, который будет выполнять скрипты из --check и --test. Если параметр не указать — будет использован интерпретатор, которым выполняется pre-commit.py.
  • -v или --verbose — включает подробное логирование. Если не указан — в лог записывается консольный вывод тех скриптов, выполнение которых завершилось с кодом ошибки.
  • -o или --openlog путь_к_просмотрщику — полный путь (с именем файла) к программе, которой будем просматривать лог.
  • -f или --forcelog — принудительное открытие лога. Если не указан — лог открывается только в случае обнаружения ошибок. Параметр применим, если указан --openlog.

Логика ясна, теперь можно приступать к написанию самого скрипта.

Параметры командной строки

Для начала настроим парсер параметров командной строки. Здесь будем использовать модуль argparse (или «на пальцах» неплохо объясняют здесь и здесь), так как он входит в базовый пакет Python.

# -*- coding: utf-8 -*-
import sys
import argparse
# Создадим объект парсера
parser = argparse.ArgumentParser()
# Добавим необязательный параметр. Если параметр задан,
#    ему необходимо указать значение: список из 1-N элементов
parser.add_argument('-c', '--check', nargs='+')
# Аналогично параметру --check
parser.add_argument('-t', '--test', nargs='+')
# Добавим параметр-флаг. Если задан, его значение будет равно
#    True. Если не задан - False
parser.add_argument('-v', '--verbose', action='store_true')
# Необязательный параметр с обязательным значением.
#    Если не задан - значение=default
parser.add_argument('-e', '--exec', default=sys.executable)
# Необязательный параметр с обязательным значением.
#    Если не задан - значение=None
parser.add_argument('-o', '--openlog')
# Аналогично параметру --verbose
parser.add_argument('-f', '--forcelog', action='store_true')
# Отсекаем 1-й параметр (имя текущего скрипта), парсим
#    остальные параметры и помещаем результат в dict
params = vars(parser.parse_args(sys.argv[1:]))

Запустим скрипт со следующими параметрами:

c:python34python c:devprojectspre-commit-toolpre-commit.py --check c:devprojectspre-commit-toolpep8.py --test tests.py

И выведем содержимое params на экран:

{'exec': 'c:\python34\python.exe', 'forcelog': False, 'test': ['tests.py'], 'check': ['c:\dev\projects\pre-commit-tool\pep8.py'], 'openlog': None, 'verbose': False}

Теперь значения всех параметров находятся в словаре params и их легко можно получить по одноименному ключу.
Добавим проверку наличия основных параметров:

# Выход в случае отсутствия обоих параметров скриптов проверок
if params.get('check') is None and params.get('test') is None:
    print('Не заданы скрипты проверок')
    exit(1)

Все хорошо, но можно немного упростить себе жизнь, без ущерба гибкости. Мы знаем, что в 99% случаев скрипт валидации один и называется он, к примеру, 'pep8.py', а скрипт юнит-тестов в нашей власти каждый раз называть одинаково (и часто он тоже будет один). Аналогично с отображением лога — всегда будем использовать одну и ту же программу (пусть это будет «Блокнот»). Внесем изменения в конфигурацию парсера:

# Теперь параметры принимают значением список из 0-N элементов
parser.add_argument('-c', '--check', nargs='*')
parser.add_argument('-t', '--test', nargs='*')
# Если параметру не указывать значение, будет использовано значение из const
parser.add_argument('-o', '--openlog', nargs='?', const='notepad')

И добавим установку значений по умолчанию:

if params.get('check') is not None and len(params.get('check')) == 0:
    # Добавляем к имени скрипта каталог, в котором pre-commit.py
    params['check'] = [join(dirname(abspath(__file__)), 'pep8.py')]
if params.get('test') is not None and len(params.get('test')) == 0:
    params['test'] = ['tests.py']

После внесения изменений код настройки парсера должен выглядеть так:

# -*- coding: utf-8 -*-
import sys
import argparse
from os.path import abspath, dirname, join

parser = argparse.ArgumentParser()
parser.add_argument('-c', '--check', nargs='*')
parser.add_argument('-t', '--test', nargs='*')
parser.add_argument('-v', '--verbose', action='store_true')
parser.add_argument('-e', '--exec', default=sys.executable)
parser.add_argument('-o', '--openlog', nargs='?', const='notepad')
parser.add_argument('-f', '--forcelog', action='store_true')
params = vars(parser.parse_args(sys.argv[1:]))

if params.get('check') is None and params.get('test') is None:
    print('Не заданы скрипты проверок')
    exit(1)

if params.get('check') is not None and len(params.get('check')) == 0:
    params['check'] = [join(dirname(abspath(__file__)), 'pep8.py')]
if params.get('test') is not None and len(params.get('test')) == 0:
    params['test'] = ['tests.py']

Теперь строка запуска скрипта стала короче:

c:python34python c:devprojectspre-commit-toolpre-commit.py --check --test --openlog

содержимое params:

{'check': ['c:\dev\projects\pre-commit-tool\pep8.py'], 'openlog': 'notepad', 'test': ['tests.py'], 'verbose': False, 'exec': 'c:\python34\python.exe', 'forcelog': False}

Параметры победили, едем дальше.

Лог

Настроим объект лога. Файл лога 'pre-commit.log' будет создаваться в корне текущего проекта. Для Git рабочим каталогом является корень проекта, поэтому путь к файлу не указываем. Также, укажем режим создания нового файла при каждой операции (нам нет необходимости хранить предыдущие логи) и зададим формат лога — только сообщение:

import logging

log_filename = 'pre-commit.log'
logging.basicConfig(
    filename=log_filename, filemode='w', format='%(message)s',
    level=logging.INFO)
to_log = logging.info

Последней строкой кода еще немного упростим себе жизнь — создаем алиас, которым будем пользоваться дальше по коду вместо logging.info.

Shell

Нам потребуется неоднократно запускать дочерние процессы и считывать их вывод в консоль. Для реализации данной потребности напишем функцию shell_command. В ее обязанности будет входить:

  • запуск подпроцесса (с помощью Popen);
  • считывание данных с консоли подпроцесса и их преобразования;
  • запись считанных данных в лог, если подпроцесс завершился с кодом ошибки.

Функция будет принимать аргументы:

  • command — аргумент для Popen. Собственно то, что будет запускать в Shell'е. Но вместо цельной строки («python main.py») рекомендуют задавать списком (['python', 'main.py']);
  • force_report — управление выводом в лог. Может принимать значения: True — принудительный вывод в лог, False — вывод, если получен код ошибки, None — запретить вывод в лог.

from subprocess import Popen, PIPE

def shell_command(command, force_report=None):
    # Запускаем подпроцесс
    proc = Popen(command, stdout=PIPE, stderr=PIPE)
    # Ожидаем его завершения
    proc.wait()
    # Функция для преобразования данных
    #    (конвертируем в строку, удаляем "rn")
    transform = lambda x: ' '.join(x.decode('utf-8').split())
    # Считываем (и преобразуем) поток stdout
    report = [transform(x) for x in proc.stdout]
    # Добавляем поток stderr
    report.extend([transform(x) for x in proc.stderr])

    # Выводим в лог зависимо от значения аргумента force_report
    if force_report is True or (force_report is not None and proc.returncode > 0):
        to_log('[ SHELL ] %s (code: %d):n%sn'
               % (' '.join(command), proc.returncode, 'n'.join(report)))

    # Возвращаем код завершения подпроцесса и консольный вывод в виде списка
    return proc.returncode, report

Head revision

Список файлов текущего commit'а легко получается с помощью консольной команды Git — «diff». В нашем случае потребуются измененные или новые файлы:

from os.path import basename

# Устанавливаем глобальный код результата
result_code = 0
# Получаем список файлов текущего commit'а
code, report = shell_command(
    ['git', 'diff', '--cached', '--name-only', '--diff-filter=ACM'],
    params.get('verbose'))
if code != 0:
    result_code = code

# Фильтруем файлы по расширению "py"
targets = filter(lambda x: x[-2:] == 'py', report)
# Добавляем каждому файлу путь (текущий каталог проекта)
targets = [join(dirname(abspath(x)), basename(x)) for x in targets]

В результате targets будет содержать нечто подобное:

['C:\dev\projects\example\demo\daemon_example.py', 'C:\dev\projects\example\main.py', 'C:\dev\projects\example\test.py', 'C:\dev\projects\example\test2.py']

Самый мучительный этап завершен — дальше будет проще.

Проверка на валидность

Здесь все просто — пройдемся по всем скриптам, заданным в --check, и запустим каждый со списком targets:

if params.get('check') is not None:
    for script in params.get('check'):
        code, report = shell_command(
            [params.get('exec'), script] + targets, params.get('verbose'))
        if code != 0:
            result_code = code

Пример содержимого лога на коде не прошедшем проверку на валидность:
[ SHELL ] C:python34python.exe c:devprojectspre-commit-toolpep8.py C:devprojectsexampledemodaemon_example.py (code: 1):
C:devprojectsexampledemodaemon_example.py:8:80: E501 line too long (80 > 79 characters)

Запуск тестов

Аналогично поступаем и с юнит-тестами, только без targets:

if params.get('test') is not None:
    for script in params.get('test'):
        code, report = shell_command(
            [params.get('exec'), script], params.get('verbose'))
        if code != 0:
            result_code = code

Отображаем лог

В зависимости от глобального кода результата и параметров --openlog и --forcelog, принимаем решение — отображать лог или нет:

if params.get('openlog') and (result_code > 0 or params.get('forcelog')):
    if sys.version_info[0] > 2:
        Popen([params.get('openlog'), log_filename], start_new_session=True)
    else:
        to_log('Открытие лога для данной версии Python не поддерживается.')

Примечание. Для Python 2.x мне не удалось запустить независимый процесс из текущего скрипта. А в этом случае скрипт будет «выполняться», пока не будет закрыт блокнот. Неудобство в том, что оболочка Git не получает управление, пока «выполняется» скрипт (не закрыт блокнот). Если кто-нибудь знает, как это победить — поделитесь решением. Буду премного благодарен.

И не забываем в конце скрипта вернуть в оболочку Git код результата:

exit(result_code)

Все. Скрипт готов к использованию.

Корень зла

Хук — это файл с именем «pre-commit» (без расширения), который нужно создать в каталоге: <каталог_проекта>/.git/hooks/

Для корректного запуска на Windows есть пара важных моментов:
1. Первая строка файла должна быть: #!/bin/sh
Иначе увидем такую ошибку:
GitHub.IO.ProcessException: error: cannot spawn .git/hooks/pre-commit: No such file or directory

2. Использование стандартного разделителя при указании пути приводит к подобной ошибке:
GitHub.IO.ProcessException: C:python34python.exe: can't open file 'c:devprojectspre-commit-toolpre-commit.py': [Errno 2] No such file or directory

Лечится тремя способами: используем двойной обратный слеш, либо берем весь путь в двойные кавычки, либо используем "/". К примеру, Windows съедает это и не давится:

#!/bin/sh
c:/python34/python "c:devprojectspre-commit-toolpre-commit.py" -c -t c:\dev\projects\example\test.py

Конечно, так делать не рекомендуется :) Используйте любой способ, который вам нравится, но один.

Приемочные испытания

Тренироваться будем «на кошках»:

image

Тестовый commit имеет новые, переименованныеизмененные и удаленные файлы. Также, включены файлы, не содержащие код; сам код содержит ошибки оформления и не проходит один из юнит-тестов. Создадим хук с валидацией, тестами и открытием подробного лога:

c:/python34/python c:/dev/projects/pre-commit-tool/pre-commit.py -c -t test.py test2.py -vfo

И пробуем выполнить commit. Подумав пару секунд, Git desktop просигналит об ошибке:

image

А в соседнем окне блокнот отобразит следующее:

[ SHELL ] git diff --cached --name-only --diff-filter=ACM (code: 0):
.gitattributes1
demo/daemon_example.py
main.py
test.py
test2.py

[ SHELL ] C:python34python.exe c:devprojectspre-commit-toolpep8.py C:devprojectsexampledemodaemon_example.py C:devprojectsexamplemain.py C:devprojectsexampletest.py C:devprojectsexampletest2.py (code: 1):
C:devprojectsexampledemodaemon_example.py:8:80: E501 line too long (80 > 79 characters)
C:devprojectsexampledemodaemon_example.py:16:5: E303 too many blank lines (2)
C:devprojectsexampledemodaemon_example.py:37:5: E303 too many blank lines (2)
C:devprojectsexampledemodaemon_example.py:47:5: E303 too many blank lines (2)
C:devprojectsexamplemain.py:46:80: E501 line too long (90 > 79 characters)
C:devprojectsexamplemain.py:59:80: E501 line too long (100 > 79 characters)
C:devprojectsexamplemain.py:63:80: E501 line too long (115 > 79 characters)
C:devprojectsexamplemain.py:69:80: E501 line too long (105 > 79 characters)
C:devprojectsexamplemain.py:98:80: E501 line too long (99 > 79 characters)
C:devprojectsexamplemain.py:115:80: E501 line too long (109 > 79 characters)
C:devprojectsexamplemain.py:120:80: E501 line too long (102 > 79 characters)
C:devprojectsexamplemain.py:123:80: E501 line too long (100 > 79 characters)

[ SHELL ] C:python34python.exe test.py (code: 1):
Test 1 - passed
Test 2 - passed
[!] Test 3 FAILED

[ SHELL ] C:python34python.exe test2.py (code: 0):
Test 1 - passed
Test 2 - passed

Повторим этот же commit, только без подробного лога:

c:/python34/python c:/dev/projects/pre-commit-tool/pre-commit.py -c -t test.py test2.py -fo

Результат:

[ SHELL ] C:python34python.exe c:devprojectspre-commit-toolpep8.py C:devprojectsexampledemodaemon_example.py C:devprojectsexamplemain.py C:devprojectsexampletest.py C:devprojectsexampletest2.py (code: 1):
C:devprojectsexampledemodaemon_example.py:8:80: E501 line too long (80 > 79 characters)
C:devprojectsexampledemodaemon_example.py:16:5: E303 too many blank lines (2)
C:devprojectsexampledemodaemon_example.py:37:5: E303 too many blank lines (2)
C:devprojectsexampledemodaemon_example.py:47:5: E303 too many blank lines (2)
C:devprojectsexamplemain.py:46:80: E501 line too long (90 > 79 characters)
C:devprojectsexamplemain.py:59:80: E501 line too long (100 > 79 characters)
C:devprojectsexamplemain.py:63:80: E501 line too long (115 > 79 characters)
C:devprojectsexamplemain.py:69:80: E501 line too long (105 > 79 characters)
C:devprojectsexamplemain.py:98:80: E501 line too long (99 > 79 characters)
C:devprojectsexamplemain.py:115:80: E501 line too long (109 > 79 characters)
C:devprojectsexamplemain.py:120:80: E501 line too long (102 > 79 characters)
C:devprojectsexamplemain.py:123:80: E501 line too long (100 > 79 characters)

[ SHELL ] C:python34python.exe test.py (code: 1):
Test 1 - passed
Test 2 - passed
[!] Test 3 FAILED

Исправим ошибки, повторим commit, и — вот он, долгожданный результат: Git desktop не ругается, а блокнот показывает пустой pre-commit.log. PROFIT.

Готовый пример можно посмотреть здесь.

Всем приятного кодинга и корректных коммитов.

Автор: ophermit

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js