Skip to content
4 changes: 4 additions & 0 deletions pre_commit/clientlib/validate_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ class InvalidConfigError(FatalError):
'type': 'array',
'items': {'type': 'string'},
},
'additional_dependencies': {
'type': 'array',
'items': {'type': 'string'}
}
},
'required': ['id'],
}
Expand Down
6 changes: 5 additions & 1 deletion pre_commit/languages/all.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@
# # Use None for no environment
# ENVIRONMENT_DIR = 'foo_env'
#
# def install_environment(repo_cmd_runner, version='default'):
# def install_environment(
# repo_cmd_runner,
# version='default',
# additional_dependencies=None,
# ):
# """Installs a repository in the given repository. Note that the current
# working directory will already be inside the repository.
#
Expand Down
14 changes: 13 additions & 1 deletion pre_commit/languages/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from pre_commit.languages import helpers
from pre_commit.util import clean_path_on_failure
from pre_commit.util import shell_escape


ENVIRONMENT_DIR = 'node_env'
Expand All @@ -23,7 +24,11 @@ def in_env(repo_cmd_runner, language_version):
yield NodeEnv(repo_cmd_runner, language_version)


def install_environment(repo_cmd_runner, version='default'):
def install_environment(
repo_cmd_runner,
version='default',
additional_dependencies=None,
):
assert repo_cmd_runner.exists('package.json')
directory = helpers.environment_dir(ENVIRONMENT_DIR, version)

Expand All @@ -41,6 +46,13 @@ def install_environment(repo_cmd_runner, version='default'):

with in_env(repo_cmd_runner, version) as node_env:
node_env.run("cd '{prefix}' && npm install -g")
if additional_dependencies:
node_env.run(
"cd '{prefix}' && npm install -g " +
' '.join(
shell_escape(dep) for dep in additional_dependencies
)
)


def run_hook(repo_cmd_runner, hook, file_args):
Expand Down
6 changes: 5 additions & 1 deletion pre_commit/languages/pcre.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@
ENVIRONMENT_DIR = None


def install_environment(repo_cmd_runner, version='default'):
def install_environment(
repo_cmd_runner,
version='default',
additional_dependencies=None,
):
"""Installation for pcre type is a noop."""
raise AssertionError('Cannot install pcre repo.')

Expand Down
14 changes: 13 additions & 1 deletion pre_commit/languages/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from pre_commit.languages import helpers
from pre_commit.util import clean_path_on_failure
from pre_commit.util import shell_escape


ENVIRONMENT_DIR = 'py_env'
Expand Down Expand Up @@ -42,7 +43,11 @@ def norm_version(version):
return version


def install_environment(repo_cmd_runner, version='default'):
def install_environment(
repo_cmd_runner,
version='default',
additional_dependencies=None,
):
assert repo_cmd_runner.exists('setup.py')
directory = helpers.environment_dir(ENVIRONMENT_DIR, version)

Expand All @@ -57,6 +62,13 @@ def install_environment(repo_cmd_runner, version='default'):
repo_cmd_runner.run(venv_cmd)
with in_env(repo_cmd_runner, version) as env:
env.run("cd '{prefix}' && pip install .")
if additional_dependencies:
env.run(
"cd '{prefix}' && pip install " +
' '.join(
shell_escape(dep) for dep in additional_dependencies
)
)


def run_hook(repo_cmd_runner, hook, file_args):
Expand Down
18 changes: 15 additions & 3 deletions pre_commit/languages/ruby.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from pre_commit.util import CalledProcessError
from pre_commit.util import clean_path_on_failure
from pre_commit.util import resource_filename
from pre_commit.util import shell_escape
from pre_commit.util import tarfile_open


Expand Down Expand Up @@ -78,7 +79,11 @@ def _install_ruby(environment, version):
environment.run('rbenv install {0}'.format(version))


def install_environment(repo_cmd_runner, version='default'):
def install_environment(
repo_cmd_runner,
version='default',
additional_dependencies=None,
):
directory = helpers.environment_dir(ENVIRONMENT_DIR, version)
with clean_path_on_failure(repo_cmd_runner.path(directory)):
# TODO: this currently will fail if there's no version specified and
Expand All @@ -88,9 +93,16 @@ def install_environment(repo_cmd_runner, version='default'):
if version != 'default':
_install_ruby(ruby_env, version)
ruby_env.run(
'cd {prefix} && gem build *.gemspec'
' && gem install --no-ri --no-rdoc *.gem',
'cd {prefix} && gem build *.gemspec && '
'gem install --no-ri --no-rdoc *.gem',
)
if additional_dependencies:
ruby_env.run(
'cd {prefix} && gem install --no-ri --no-rdoc ' +
' '.join(
shell_escape(dep) for dep in additional_dependencies
)
)


def run_hook(repo_cmd_runner, hook, file_args):
Expand Down
7 changes: 5 additions & 2 deletions pre_commit/languages/script.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@
ENVIRONMENT_DIR = None


def install_environment(repo_cmd_runner, version='default'):
def install_environment(
repo_cmd_runner,
version='default',
additional_dependencies=None,
):
"""Installation for script type is a noop."""
raise AssertionError('Cannot install script repo.')


def run_hook(repo_cmd_runner, hook, file_args):
return repo_cmd_runner.run(
['xargs', '-0', '{{prefix}}{0}'.format(hook['entry'])] + hook['args'],
# TODO: this is duplicated in pre_commit/languages/helpers.py
stdin=file_args_to_stdin(file_args),
retcode=None,
encoding=None,
Expand Down
6 changes: 5 additions & 1 deletion pre_commit/languages/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
ENVIRONMENT_DIR = None


def install_environment(repo_cmd_runner, version='default'):
def install_environment(
repo_cmd_runner,
version='default',
additional_dependencies=None,
):
"""Installation for system type is a noop."""
raise AssertionError('Cannot install system repo.')

Expand Down
13 changes: 12 additions & 1 deletion pre_commit/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import logging
import shutil
from collections import defaultdict

from cached_property import cached_property

Expand Down Expand Up @@ -49,6 +50,14 @@ def languages(self):
for _, hook in self.hooks
)

@cached_property
def additional_dependencies(self):
dep_dict = defaultdict(lambda: defaultdict(set))
for _, hook in self.hooks:
dep_dict[hook['language']][hook['language_version']].update(
hook.get('additional_dependencies', []))
return dep_dict

@cached_property
def hooks(self):
# TODO: merging in manifest dicts is a smell imo
Expand Down Expand Up @@ -107,7 +116,9 @@ def language_is_installed(language_name, language_version):
if self.cmd_runner.exists(directory):
shutil.rmtree(self.cmd_runner.path(directory))

language.install_environment(self.cmd_runner, language_version)
language.install_environment(
self.cmd_runner, language_version,
self.additional_dependencies[language_name][language_version])
# Touch the .installed file (atomic) to indicate we've installed
open(self.cmd_runner.path(directory, '.installed'), 'w').close()

Expand Down
4 changes: 2 additions & 2 deletions tests/languages/all_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
@pytest.mark.parametrize('language', all_languages)
def test_install_environment_argspec(language):
expected_argspec = inspect.ArgSpec(
args=['repo_cmd_runner', 'version'],
args=['repo_cmd_runner', 'version', 'additional_dependencies'],
varargs=None,
keywords=None,
defaults=('default',),
defaults=('default', None),
)
argspec = inspect.getargspec(languages[language].install_environment)
assert argspec == expected_argspec
Expand Down
57 changes: 53 additions & 4 deletions tests/repository_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@
from pre_commit.clientlib.validate_config import CONFIG_JSON_SCHEMA
from pre_commit.clientlib.validate_config import validate_config_extra
from pre_commit.jsonschema_extensions import apply_defaults
from pre_commit.languages.python import norm_version
from pre_commit.languages.python import PythonEnv
from pre_commit.languages import node
from pre_commit.languages import python
from pre_commit.languages import ruby
from pre_commit.repository import Repository
from pre_commit.util import cmd_output
from pre_commit.util import cwd
Expand Down Expand Up @@ -278,6 +279,7 @@ def mock_repo_config():
'hooks': [{
'id': 'pyflakes',
'files': '\\.py$',
'additional_dependencies': ['pep8']
}],
}
config_wrapped = apply_defaults([config], CONFIG_JSON_SCHEMA)
Expand All @@ -303,6 +305,53 @@ def test_languages(tempdir_factory, store):
assert repo.languages == set([('python', 'default')])


@pytest.mark.integration
def test_additional_dependencies(tempdir_factory, store):
path = make_repo(tempdir_factory, 'python_hooks_repo')
config = make_config_from_repo(path)
config['hooks'][0]['additional_dependencies'] = ['pep8']
repo = Repository.create(config, store)
assert repo.additional_dependencies['python']['default'] == set(('pep8',))


@pytest.mark.integration
def test_additional_python_dependencies_installed(tempdir_factory, store):
path = make_repo(tempdir_factory, 'python_hooks_repo')
config = make_config_from_repo(path)
config['hooks'][0]['additional_dependencies'] = ['mccabe']
repo = Repository.create(config, store)
repo.run_hook(repo.hooks[0][1], [])
with python.in_env(repo.cmd_runner, 'default') as env:
output = env.run('pip freeze -l')[1]
assert 'mccabe' in output


@pytest.mark.integration
def test_additional_ruby_dependencies_installed(tempdir_factory, store):
path = make_repo(tempdir_factory, 'ruby_hooks_repo')
config = make_config_from_repo(path)
config['hooks'][0]['additional_dependencies'] = ['mime-types']
repo = Repository.create(config, store)
repo.run_hook(repo.hooks[0][1], [])
with ruby.in_env(repo.cmd_runner, 'default') as env:
output = env.run('gem list --local')[1]
assert 'mime-types' in output


@pytest.mark.integration
def test_additional_node_dependencies_installed(tempdir_factory, store):
path = make_repo(tempdir_factory, 'node_hooks_repo')
config = make_config_from_repo(path)
# Careful to choose a small package that's not depped by npm
config['hooks'][0]['additional_dependencies'] = ['lodash']
repo = Repository.create(config, store)
repo.run_hook(repo.hooks[0][1], [])
with node.in_env(repo.cmd_runner, 'default') as env:
env.run('npm config set global true')
output = env.run(('npm ls'))[1]
assert 'lodash' in output


def test_reinstall(tempdir_factory, store, log_info_mock):
path = make_repo(tempdir_factory, 'python_hooks_repo')
config = make_config_from_repo(path)
Expand Down Expand Up @@ -335,7 +384,7 @@ class MyKeyboardInterrupt(KeyboardInterrupt):
# raise as well.
with pytest.raises(MyKeyboardInterrupt):
with mock.patch.object(
PythonEnv, 'run', side_effect=MyKeyboardInterrupt,
python.PythonEnv, 'run', side_effect=MyKeyboardInterrupt,
):
with mock.patch.object(
shutil, 'rmtree', side_effect=MyKeyboardInterrupt,
Expand Down Expand Up @@ -426,5 +475,5 @@ def test_norm_version_expanduser(): # pragma: no cover
else:
path = '~/.pyenv/versions/3.4.3/bin/python'
expected_path = home + '/.pyenv/versions/3.4.3/bin/python'
result = norm_version(path)
result = python.norm_version(path)
assert result == expected_path