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

  • Tutorial
Думаю, нет нужды рассказывать хабрапользователю что такое 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:\python34\python c:\dev\projects\pre-commit-tool\pre-commit.py --check c:\dev\projects\pre-commit-tool\pep8.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:\python34\python c:\dev\projects\pre-commit-tool\pre-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()
    # Функция для преобразования данных
    #    (конвертируем в строку, удаляем "\r\n")
    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%s\n'
               % (' '.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.split('.')[-1] == "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:\python34\python.exe c:\dev\projects\pre-commit-tool\pep8.py C:\dev\projects\example\demo\daemon_example.py (code: 1):
C:\dev\projects\example\demo\daemon_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

[UPD] Отображаем лог


В зависимости от глобального кода результата и параметров --openlog и --forcelog, принимаем решение — отображать лог или нет:
if params.get('openlog') and (result_code > 0 or params.get('forcelog')):
    # Запускаем независимый процесс
    Popen([params.get('openlog'), log_filename], close_fds=True)

Примечание. Работает в версиях Python 2.6 (и выше) и 3.х. На версиях, ниже 2.6 — тесты не проводились

И не забываем в конце скрипта вернуть в оболочку 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:\python34\python.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:\dev\projects\pre-commit-tool\pre-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:\python34\python.exe c:\dev\projects\pre-commit-tool\pep8.py 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 (code: 1):
C:\dev\projects\example\demo\daemon_example.py:8:80: E501 line too long (80 > 79 characters)
C:\dev\projects\example\demo\daemon_example.py:16:5: E303 too many blank lines (2)
C:\dev\projects\example\demo\daemon_example.py:37:5: E303 too many blank lines (2)
C:\dev\projects\example\demo\daemon_example.py:47:5: E303 too many blank lines (2)
C:\dev\projects\example\main.py:46:80: E501 line too long (90 > 79 characters)
C:\dev\projects\example\main.py:59:80: E501 line too long (100 > 79 characters)
C:\dev\projects\example\main.py:63:80: E501 line too long (115 > 79 characters)
C:\dev\projects\example\main.py:69:80: E501 line too long (105 > 79 characters)
C:\dev\projects\example\main.py:98:80: E501 line too long (99 > 79 characters)
C:\dev\projects\example\main.py:115:80: E501 line too long (109 > 79 characters)
C:\dev\projects\example\main.py:120:80: E501 line too long (102 > 79 characters)
C:\dev\projects\example\main.py:123:80: E501 line too long (100 > 79 characters)

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

[ SHELL ] C:\python34\python.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:\python34\python.exe c:\dev\projects\pre-commit-tool\pep8.py 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 (code: 1):
C:\dev\projects\example\demo\daemon_example.py:8:80: E501 line too long (80 > 79 characters)
C:\dev\projects\example\demo\daemon_example.py:16:5: E303 too many blank lines (2)
C:\dev\projects\example\demo\daemon_example.py:37:5: E303 too many blank lines (2)
C:\dev\projects\example\demo\daemon_example.py:47:5: E303 too many blank lines (2)
C:\dev\projects\example\main.py:46:80: E501 line too long (90 > 79 characters)
C:\dev\projects\example\main.py:59:80: E501 line too long (100 > 79 characters)
C:\dev\projects\example\main.py:63:80: E501 line too long (115 > 79 characters)
C:\dev\projects\example\main.py:69:80: E501 line too long (105 > 79 characters)
C:\dev\projects\example\main.py:98:80: E501 line too long (99 > 79 characters)
C:\dev\projects\example\main.py:115:80: E501 line too long (109 > 79 characters)
C:\dev\projects\example\main.py:120:80: E501 line too long (102 > 79 characters)
C:\dev\projects\example\main.py:123:80: E501 line too long (100 > 79 characters)

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


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

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

[UPD] Вместо заключения


Конечно, данный скрипт — не панацея. Он полезен, когда все необходимые проверки ограничиваются локальным запуском проверочных скриптов. В комплексных проектах обычно применяется концепция Непрерывной интеграции (или CI), и здесь на помощь приходят Travis (для Linux и OS X) и его аналог AppVeyor (для Windows).

[UPD] Еще одна альтернатива — overcommit. Довольно функциональный инструмент для управления хуками Git. Но есть нюансы — для работы overcommit необходимо локально развернуть интерпретатор Ruby.

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

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Чего не хватает в статье (в качестве обучающего материала)?

Поделиться публикацией

Комментарии 16

    +2
    Это все хорошо если у вас только pep и несколько юнит тестов. Но если у вас множество разные утилит, компиляторов и флагов…
    Поэтому, мне кажется, лучше использовать ci. Для github это travis и appveyor.
      0
      Дельное замечание, спасибо. Добавил в статью.
      +1
      Чтобы процесс детачнулся под линуксом достаточно передать close_fds = True в аргументах к Popen(), хотя под виндой может потребоваться поиграть с флагом DETACHED_PROCESS (http://stackoverflow.com/questions/13592219/launch-a-totally-independent-process-from-python).

      Добавлю, что человек, который закоммитит файл some_file_which_ends_with_py, game.spy, например, будет неприятно удивлен.
        0
        Спасибо, исправил)

        P.S. С DETACHED_PROCESS поэкспериментирую завтра, это похоже на выход. Позже отпишусь.
          0
          Флаг DETACHED_PROCESS привязан к платформе, его использование нежелательно, чтобы не добавлять лишних проверок на текущую OS. А вот close_fds оказался панацеей, если не трогать std-потоки.

          Статью обновил.

          Большое спасибо за подсказку.
          0
          только вчера читал про overcommit
            0
            Ответ — ниже.
            +2
            Упомяну, что для Ruby (и как понимаю он может быть расширен для любого языка) — есть прекрасный gem overcommit — github.com/brigade/overcommit, который реализует все сказанное в статье.
              0
              ну вот опять зарекаюсь писать комментарии, пока его проверят — уже будет готов аналогичный комментарий от другого человека
                0
                Извини, не успел)
                0
                Тоже полезный инструмент, и довольно-таки функциональный. Но, я так понимаю, для его работоспособности необходимо локально развернуть интерпретатор Ruby?
                  0
                  Да, необходим Ruby, но насколько я знаю должно хватить версии из поставки с дистрибутивом, без плясок с rvm, так что все довольно аналогично по требованиям к системе, как и в скриптом на python из статьи.
                    0
                    Пожалуй, тоже нужно добавить в статью, как одну из альтернатив. Кстати, а насколько хорошо он дружит с Windows?
                      0
                      Судя по документации (и наличию костылей в коде для поддержки Windows) — должно дружить с некоторым ограничением функциональности.
                0
                Но вдруг появляется необходимость внести изменения в хук, который уже живет в 20 проектах… Или внезапно нужно переносить разработку с Windows на Linux, а хук на PowerShell'е… Что делать?

                А как такое решение: завести 21-й проект для хуков? Локальных хук будет лишь подгружать «главный» скрипт из репозитория. Этот главный скрипт определяет ОС и загружает другой скрипт, выполняющий непосредственно проверку.
                  +1
                  Можно. Только зачем так усложнять? Лучше, когда есть один код, который одинаково выполняется под всеми необходимыми ОС

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое