Автоматизуємо перевірку коду або ще трохи про pre-commit hook'ах

Думаю, немає потреби розповідати хабрапользователю що таке 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 and None 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[-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:\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

Відображаємо лог

Залежно від глобального коду результату і параметрів --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:\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.

Готовий приклад можна подивитися тут.

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

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.