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:
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