hookmeup

A Git hook to automate your Pipenv and Django workflow
git clone git://git.danielmoch.com/hookmeup.git
Log | Files | Refs | README | LICENSE

commit 016f610bd45685cc7bb90fdd233cc52da9c86d9a
parent d7b8a0a0561b43df0e7aeb926d62cd339412b92b
Author: Daniel Moch <daniel@danielmoch.com>
Date:   Sun, 19 Aug 2018 20:47:08 -0400

Working for Pipfile

Still TBD:
- Handle Django migrations

Diffstat:
MMakefile | 9++++++++-
MPipfile | 4+++-
MPipfile.lock | 36++++++++++++++++++++++++++++++++----
MREADME.md | 4++++
Mhookmeup/__init__.py | 29+++++++++++++++++++++++++++++
Mhookmeup/hookmeup.py | 99++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpyproject.toml | 3+++
Dtests/__init__.py | 3---
Mtests/pylintrc | 7++++---
Mtests/test_hookmeup.py | 174+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Atests/test_main.py | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
11 files changed, 413 insertions(+), 25 deletions(-)

diff --git a/Makefile b/Makefile @@ -44,6 +44,7 @@ clean-pyc: ## remove Python file artifacts find . -name '*.pyo' -exec rm -f {} + find . -name '*~' -exec rm -f {} + find . -name '__pycache__' -exec rm -fr {} + + $(PIPENV) pip uninstall -y hookmeup clean-test: ## remove test and coverage artifacts rm -f .coverage @@ -57,7 +58,7 @@ test: ## run tests quickly with the default Python $(PIPENV) python -m pytest coverage: ## check code coverage quickly with the default Python - $(PIPENV) coverage run --source hookmeup -m pytest + $(PIPENV) python -m pytest $(PIPENV) coverage report -m $(PIPENV) coverage html $(BROWSER) htmlcov/index.html @@ -71,3 +72,9 @@ dist: clean ## builds source and wheel package install: clean ## install the package to the active Python's site-packages $(PIPENV) flit install + +run: install ## run the package from site-packages + $(PIPENV) hookmeup install + +debug: install ## debug the package from site packages + $(PIPENV) pudb3 `pipenv --venv`/bin/hookmeup install diff --git a/Pipfile b/Pipfile @@ -7,9 +7,11 @@ name = "pypi" flit = "*" [dev-packages] -pytest = "*" +pytest = "<3.7.0" pytest-cov = "*" pytest-pylint = "*" +pytest-mock = "*" +pudb = "*" [requires] python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "ebd85560c80b75d78d340bf9f3e18d6d4352aa5265f1948ff913ea254881ecee" + "sha256": "fd5a5de24dc062a9d5c8bfdd4adde56e660fd0f62c616dac3732592b290123b2" }, "pipfile-spec": 6, "requires": { @@ -207,6 +207,13 @@ "markers": "python_version != '3.0.*' and python_version != '3.2.*' and python_version != '3.1.*' and python_version != '3.3.*' and python_version >= '2.7'", "version": "==0.7.1" }, + "pudb": { + "hashes": [ + "sha256:8d8b974641b7a7a2a721af01c9dce5eac8e05a2ceebc2680725ba8eef1ca876e" + ], + "index": "pypi", + "version": "==2018.1" + }, "py": { "hashes": [ "sha256:3fd59af7435864e1a243790d322d763925431213b6b8529c6ca71081ace3bbf7", @@ -215,6 +222,13 @@ "markers": "python_version != '3.0.*' and python_version != '3.2.*' and python_version != '3.1.*' and python_version != '3.3.*' and python_version >= '2.7'", "version": "==1.5.4" }, + "pygments": { + "hashes": [ + "sha256:78f3f434bcc5d6ee09020f92ba487f95ba50f1e3ef83ae96b9d5ffa1bab25c5d", + "sha256:dbae1046def0efb574852fab9e90209b23f556367b5a320c0bcb871c77c3e8cc" + ], + "version": "==2.2.0" + }, "pylint": { "hashes": [ "sha256:1d6d3622c94b4887115fe5204982eee66fdd8a951cf98635ee5caee6ec98c3ec", @@ -224,11 +238,11 @@ }, "pytest": { "hashes": [ - "sha256:3459a123ad5532852d36f6f4501dfe1acf4af1dd9541834a164666aa40395b02", - "sha256:96bfd45dbe863b447a3054145cd78a9d7f31475d2bce6111b133c0cc4f305118" + "sha256:341ec10361b64a24accaec3c7ba5f7d5ee1ca4cebea30f76fad3dd12db9f0541", + "sha256:952c0389db115437f966c4c2079ae9d54714b9455190e56acebe14e8c38a7efa" ], "index": "pypi", - "version": "==3.7.2" + "version": "==3.6.4" }, "pytest-cov": { "hashes": [ @@ -238,6 +252,14 @@ "index": "pypi", "version": "==2.5.1" }, + "pytest-mock": { + "hashes": [ + "sha256:53801e621223d34724926a5c98bd90e8e417ce35264365d39d6c896388dcc928", + "sha256:d89a8209d722b8307b5e351496830d5cc5e192336003a485443ae9adeb7dd4c0" + ], + "index": "pypi", + "version": "==1.10.0" + }, "pytest-pylint": { "hashes": [ "sha256:5e39b9e1c306319500779f5335ffa35bcc78e973bed61d069b5ce9058ca59f32", @@ -254,6 +276,12 @@ ], "version": "==1.11.0" }, + "urwid": { + "hashes": [ + "sha256:644d3e3900867161a2fc9287a9762753d66bd194754679adb26aede559bcccbc" + ], + "version": "==2.0.1" + }, "wrapt": { "hashes": [ "sha256:d4d560d479f2c21e1b5443bbd15fe7ec4b37fe7e53d335d3b9b0a7b1226fe3c6" diff --git a/README.md b/README.md @@ -2,6 +2,10 @@ A Git hook to automate your Pipenv and Django workflows +# Requires + +- Python 3.5 or newer + # Features * TODO diff --git a/hookmeup/__init__.py b/hookmeup/__init__.py @@ -1,8 +1,37 @@ # -*- coding: utf-8 -*- """A Git hook to automate your Pipenv and Django workflows""" +import argparse + +from . import hookmeup __author__ = 'Daniel Moch' __email__ = 'daniel@danielmoch.com' __version__ = '0.1.0' __copyright__ = 'Copyright (c) 2018, Daniel Moch' + +def main(): + """Main hookmeup entrypoint""" + parser = argparse.ArgumentParser() + parser.add_argument('-v', action='version', version='%(prog)s 0.1.0') + subparsers = parser.add_subparsers( + title='subcommands', + description='Valid %(prog)s subcommands') + install_parser = subparsers.add_parser( + 'install', + description='Install hook into repository') + install_parser.set_defaults(func=hookmeup.install) + post_commit_parser = subparsers.add_parser( + 'post-checkout', + description='Run post-checkout hook') + post_commit_parser.add_argument('old', help='the old commit') + post_commit_parser.add_argument('new', help='the new commit') + post_commit_parser.add_argument( + 'branch_checkout', + help='1 for branch checkout, 0 otherwise') + post_commit_parser.set_defaults(func=hookmeup.post_checkout) + args = parser.parse_args() + func = args.func + arg_dict = vars(args) + del arg_dict['func'] + func(arg_dict) diff --git a/hookmeup/hookmeup.py b/hookmeup/hookmeup.py @@ -1,3 +1,100 @@ # -*- coding: utf-8 -*- +"""hookmeup module.""" +import os +import subprocess -"""Main module.""" +class HookMeUpError(Exception): + """Errors raised by hookmeup""" + EXIT_CODE = 1 + + def __str__(self): + return "hookmeup: {}".format(self.args[0]) + +def handle_completed_process(completed_process, msg="fatal error"): + """Handle return data from a call to subprocess.run""" + if completed_process.returncode != 0: + raise HookMeUpError(msg) + +def adjust_pipenv(): + """Adjust pipenv to match Pipfile""" + print('Adjusting virtualenv to match Pipfile') + completed_process = subprocess.run( + ['pipenv', 'clean'], + check=True + ) + handle_completed_process( + completed_process, + 'Attempt to clean pipenv failed' + ) + + completed_process = subprocess.run( + ['pipenv', 'sync', '--dev'], + check=True + ) + handle_completed_process( + completed_process, + 'Attempt to sync pipenv failed' + ) + +def pipfile_changed(args): + """Test if the Pipfile has changed""" + completed_process = subprocess.run( + ['git', + 'diff', + '--name-status', + args['old'], + args['new'], + '--', + 'Pipfile'], + check=True, + capture_output=True + ) + handle_completed_process( + completed_process, + 'Not in a Git repository' + ) + + return completed_process.stdout.decode('utf-8').startswith('M') + +def post_checkout(args): + """Run post-checkout hook""" + if args['branch_checkout'] == 1: + if pipfile_changed(args): + adjust_pipenv() + +def install(args): + """Install hook into repository""" + if len(args) is not 0: + raise HookMeUpError( + "Argument passed to 'install', but expected none" + ) + + completed_process = subprocess.run( + ['git', 'rev-parse', '--git-dir'], + check=True, + capture_output=True + ) + + handle_completed_process( + completed_process, + 'Not in a Git repository' + ) + + hook_path = os.path.join( + completed_process.stdout.decode('utf-8').strip(), + 'hooks', + 'post-checkout' + ) + + if os.path.exists(hook_path): + with open(hook_path, 'r') as hook_file: + already_installed = 'hookmeup' in hook_file.read() + + with open(hook_path, 'a') as hook_file: + if already_installed: + print('hookmeup already installed') + else: + hook_file.write('hookmeup post-checkout "$@"\n') + else: + with open(hook_path, 'w') as hook_file: + hook_file.write('#!/bin/sh\nhookmeup post-checkout "$@"\n') diff --git a/pyproject.toml b/pyproject.toml @@ -9,3 +9,6 @@ author-email = "daniel@danielmoch.com" home-page = "https://github.com/djmoch/hookmeup" description-file = "README.md" classifiers = ["License :: OSI Approved :: MIT License"] + +[tool.flit.scripts] +hookmeup = "hookmeup:main" diff --git a/tests/__init__.py b/tests/__init__.py @@ -1,3 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Unit test package for hookmeup.""" diff --git a/tests/pylintrc b/tests/pylintrc @@ -11,7 +11,7 @@ ignore=CVS .git # Add files or directories matching the regex patterns to the blacklist. The # regex matches against base names, not paths. -ignore-patterns=version.* +ignore-patterns= # Python code to execute, usually for sys.path manipulation such as # pygtk.require(). @@ -60,7 +60,7 @@ confidence= # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use "--disable=all --enable=classes # --disable=W". -disable=missing-docstring, +disable=unsubscriptable-object, print-statement, parameter-unpacking, unpacking-in-except, @@ -238,7 +238,8 @@ generated-members=REQUEST, aq_parent, assert_called_once_with, assert_called_once, - call_count + call_count, + print # Tells whether missing members accessed in mixin class should be ignored. A # mixin class is detected if its name ends with "mixin" (case insensitive). diff --git a/tests/test_hookmeup.py b/tests/test_hookmeup.py @@ -1,24 +1,174 @@ # -*- coding: utf-8 -*- """Tests for `hookmeup` package.""" +import os +import subprocess import pytest +import hookmeup +@pytest.fixture +def mock_install(mocker): + """Mock low-level API's called by install""" + completed_process = subprocess.CompletedProcess( + args=['git', 'rev-parse', '--git-dir'], + returncode=0, + stdout=b'.git', + stderr=b'' + ) + mocker.patch( + 'subprocess.run', + new=mocker.MagicMock(return_value=completed_process) + ) -from hookmeup import hookmeup +def test_install(mock_install, mocker): + """Test install function""" + mock_file = mocker.mock_open() + mocker.patch('hookmeup.hookmeup.open', mock_file) + mocker.patch( + 'os.path.exists', + new=mocker.MagicMock(return_value=False) + ) + hookmeup.hookmeup.install({}) + mock_file.assert_called_once_with('.git/hooks/post-checkout', 'w') + mock_file().write.assert_called_once_with( + '#!/bin/sh\nhookmeup post-checkout "$@"\n' + ) + os.path.exists.assert_called_once_with('.git/hooks/post-checkout') +def test_install_existing_hook(mock_install, mocker): + """Test install function when post-checkout already exists""" + mock_file = mocker.mock_open() + mocker.patch('hookmeup.hookmeup.open', mock_file) + mocker.patch( + 'os.path.exists', + new=mocker.MagicMock(return_value=True) + ) + hookmeup.hookmeup.install({}) + assert mock_file.call_count == 2 + os.path.exists.assert_called_once_with('.git/hooks/post-checkout') -@pytest.fixture -def response(): - """Sample pytest fixture. +def test_install_bad_arg(mocker): + """Test install function when arg inappropriately provided""" + with pytest.raises(hookmeup.hookmeup.HookMeUpError): + hookmeup.hookmeup.install({'oops': 'don\t do this'}) + +def test_install_outside_repo(mocker): + """Test install outside of Git repository""" + completed_process = subprocess.CompletedProcess( + args=['git', 'rev-parse', '--git-dir'], + returncode=128, + stdout=b'', + stderr=b'fatal: not a git repository' + ) + mocker.patch( + 'subprocess.run', + new=mocker.MagicMock(return_value=completed_process) + ) + with pytest.raises(hookmeup.hookmeup.HookMeUpError): + hookmeup.hookmeup.install({}) + +def test_install_already_installed(mock_install, mocker): + """Test attempt to install when hook already installed""" + mock_file = mocker.mock_open( + read_data='#!/bin/sh\nhookmeup post-checkout\n' + ) + mocker.patch('hookmeup.hookmeup.open', mock_file) + mocker.patch( + 'os.path.exists', + new=mocker.MagicMock(return_value=True) + ) + mocker.patch('hookmeup.hookmeup.print') + hookmeup.hookmeup.install({}) + hookmeup.hookmeup.print.assert_called_once() + +def test_error(): + """Test accessing error members""" + try: + raise hookmeup.hookmeup.HookMeUpError('test error') + except hookmeup.hookmeup.HookMeUpError as error: + assert str(error) == 'hookmeup: test error' + +def test_post_checkout(mocker): + """Test nominal post_checkout""" + completed_process = subprocess.CompletedProcess( + args=['git', + 'diff', + '--name-status', + 'HEAD^', + 'HEAD', + '--', + 'Pipfile'], + returncode=0, + stdout=b'M Pipfile\n', + stderr=b'' + ) + mocker.patch( + 'subprocess.run', + new=mocker.MagicMock(return_value=completed_process) + ) + mocker.patch('hookmeup.hookmeup.adjust_pipenv') + hookmeup.hookmeup.post_checkout({ + 'branch_checkout': 1, + 'old': 'HEAD^', + 'new': 'HEAD' + }) + subprocess.run.assert_called_once() + hookmeup.hookmeup.adjust_pipenv.assert_called_once() - See more at: http://doc.pytest.org/en/latest/fixture.html - """ - # import requests - # return requests.get('https://github.com/audreyr/cookiecutter-pypackage') +def test_post_checkout_no_changes(mocker): + """Test nominal post_checkout""" + completed_process = subprocess.CompletedProcess( + args=['git', + 'diff', + '--name-status', + 'HEAD^', + 'HEAD', + '--', + 'Pipfile'], + returncode=0, + stdout=b'\n', + stderr=b'' + ) + mocker.patch( + 'subprocess.run', + new=mocker.MagicMock(return_value=completed_process) + ) + mocker.patch('hookmeup.hookmeup.adjust_pipenv') + hookmeup.hookmeup.post_checkout({ + 'branch_checkout': 1, + 'old': 'HEAD^', + 'new': 'HEAD' + }) + subprocess.run.assert_called_once() + assert hookmeup.hookmeup.adjust_pipenv.call_count == 0 +def test_adjust_pipenv(mocker): + """Test call to adjust_pipenv""" + completed_process = subprocess.CompletedProcess( + args=['pipenv', 'clean'], + returncode=0, + stdout=b'.git', + stderr=b'' + ) + mocker.patch( + 'subprocess.run', + new=mocker.MagicMock(return_value=completed_process) + ) + hookmeup.hookmeup.adjust_pipenv() + assert subprocess.run.call_count == 2 -def test_content(response): - """Sample pytest test function with the pytest fixture as an argument.""" - # from bs4 import BeautifulSoup - # assert 'GitHub' in BeautifulSoup(response.content).title.string +def test_adjust_pipenv_failure(mocker): + """Test adjust_pipenv with failed subprocess call""" + completed_process = subprocess.CompletedProcess( + args=['pipenv', 'clean'], + returncode=1, + stdout=b'.git', + stderr=b'' + ) + mocker.patch( + 'subprocess.run', + new=mocker.MagicMock(return_value=completed_process) + ) + with pytest.raises(hookmeup.hookmeup.HookMeUpError): + hookmeup.hookmeup.adjust_pipenv() diff --git a/tests/test_main.py b/tests/test_main.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- + +"""Tests for `hookmeup` package.""" +import sys + +import pytest +import hookmeup + +@pytest.fixture +def mock_hookmeup(mocker): + """Mock hookmeup subcommands""" + mocker.patch('hookmeup.hookmeup.install') + mocker.patch('hookmeup.hookmeup.post_checkout') + +def test_main_install(mock_hookmeup, mocker): + """Test the entrypoint with the install subcommand.""" + mocker.patch.object(sys, 'argv', ['hookmeup', 'install']) + hookmeup.main() + hookmeup.hookmeup.install.assert_called_once() + assert hookmeup.hookmeup.post_checkout.call_count == 0 + +def test_install_too_many_args(mock_hookmeup, mocker): + """Test install with too many arguments""" + mocker.patch.object( + sys, + 'argv', + ['hookmeup', 'post-checkout', '1'] + ) + with pytest.raises(SystemExit): + hookmeup.main() + assert hookmeup.hookmeup.post_checkout.call_count == 0 + assert hookmeup.hookmeup.install.call_count == 0 + +def test_main_post_checkout(mock_hookmeup, mocker): + """ Test the entrypoint with the post-checkout subcommand and good + arguments.""" + mocker.patch.object( + sys, + 'argv', + ['hookmeup', 'post-checkout', '1', '2', '3'] + ) + hookmeup.main() + hookmeup.hookmeup.post_checkout.assert_called_once_with( + {'old': '1', 'new': '2', 'branch_checkout': '3'} + ) + assert hookmeup.hookmeup.install.call_count == 0 + +def test_pc_too_few_args(mock_hookmeup, mocker): + """Test post-checkout with too few arguments""" + mocker.patch.object( + sys, + 'argv', + ['hookmeup', 'post-checkout', '1', '2'] + ) + with pytest.raises(SystemExit): + hookmeup.main() + assert hookmeup.hookmeup.post_checkout.call_count == 0 + assert hookmeup.hookmeup.install.call_count == 0 + +def test_pc_too_many_args(mock_hookmeup, mocker): + """Test post-checkout with too many arguments""" + mocker.patch.object( + sys, + 'argv', + ['hookmeup', 'post-checkout', '1', '2', '3', '4'] + ) + with pytest.raises(SystemExit): + hookmeup.main() + assert hookmeup.hookmeup.post_checkout.call_count == 0 + assert hookmeup.hookmeup.install.call_count == 0