В сети много примеров хуков, большинство из них на shell'ах, но ни один автор не уделил внимание одному важному моменту — хук приходится таскать из проекта в проект. На первый взгляд — ничего страшного. Но вдруг появляется необходимость внести изменения в хук, который уже живет в 20 проектах… Или внезапно нужно переносить разработку с Windows на Linux, а хук на PowerShell'е… Что делать?
«Лучше так: 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
Конечно, так делать не рекомендуется :) Используйте любой способ, который вам нравится, но один.
Приемочные испытания
Тренироваться будем «на кошках»:
Тестовый commit имеет новые, переименованные\измененные и удаленные файлы. Также, включены файлы, не содержащие код; сам код содержит ошибки оформления и не проходит один из юнит-тестов. Создадим хук с валидацией, тестами и открытием подробного лога:
c:/python34/python c:/dev/projects/pre-commit-tool/pre-commit.py -c -t test.py test2.py -vfo
И пробуем выполнить commit. Подумав пару секунд, Git desktop просигналит об ошибке:
А в соседнем окне блокнот отобразит следующее:
[ 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)
Grief
15.06.2016 04:00+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, например, будет неприятно удивлен.ophermit
15.06.2016 04:07Спасибо, исправил)
P.S. С DETACHED_PROCESS поэкспериментирую завтра, это похоже на выход. Позже отпишусь.
ophermit
15.06.2016 15:24Флаг DETACHED_PROCESS привязан к платформе, его использование нежелательно, чтобы не добавлять лишних проверок на текущую OS. А вот close_fds оказался панацеей, если не трогать std-потоки.
Статью обновил.
Большое спасибо за подсказку.
ShockwaveNN
15.06.2016 18:47+2Упомяну, что для Ruby (и как понимаю он может быть расширен для любого языка) — есть прекрасный gem overcommit — github.com/brigade/overcommit, который реализует все сказанное в статье.
ShockwaveNN
15.06.2016 19:28ну вот опять зарекаюсь писать комментарии, пока его проверят — уже будет готов аналогичный комментарий от другого человека
ophermit
15.06.2016 19:30Тоже полезный инструмент, и довольно-таки функциональный. Но, я так понимаю, для его работоспособности необходимо локально развернуть интерпретатор Ruby?
ShockwaveNN
16.06.2016 00:40Да, необходим Ruby, но насколько я знаю должно хватить версии из поставки с дистрибутивом, без плясок с rvm, так что все довольно аналогично по требованиям к системе, как и в скриптом на python из статьи.
ophermit
16.06.2016 02:18Пожалуй, тоже нужно добавить в статью, как одну из альтернатив. Кстати, а насколько хорошо он дружит с Windows?
ShockwaveNN
16.06.2016 10:36Судя по документации (и наличию костылей в коде для поддержки Windows) — должно дружить с некоторым ограничением функциональности.
okazymyrov
19.06.2016 18:00Но вдруг появляется необходимость внести изменения в хук, который уже живет в 20 проектах… Или внезапно нужно переносить разработку с Windows на Linux, а хук на PowerShell'е… Что делать?
А как такое решение: завести 21-й проект для хуков? Локальных хук будет лишь подгружать «главный» скрипт из репозитория. Этот главный скрипт определяет ОС и загружает другой скрипт, выполняющий непосредственно проверку.ophermit
20.06.2016 21:08+1Можно. Только зачем так усложнять? Лучше, когда есть один код, который одинаково выполняется под всеми необходимыми ОС
4144
Это все хорошо если у вас только pep и несколько юнит тестов. Но если у вас множество разные утилит, компиляторов и флагов…
Поэтому, мне кажется, лучше использовать ci. Для github это travis и appveyor.
ophermit
Дельное замечание, спасибо. Добавил в статью.