Думаю, нет нужды рассказывать хабрапользователю что такое 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
Конечно, так делать не рекомендуется :) Используйте любой способ, который вам нравится, но один.
Приемочные испытания
Тренироваться будем «на кошках»:
Тестовый 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: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