diff --git a/pre_commit/clientlib.py b/pre_commit/clientlib.py index 11750b74..3c086cb9 100644 --- a/pre_commit/clientlib.py +++ b/pre_commit/clientlib.py @@ -98,6 +98,8 @@ def validate_manifest_main(argv=None): _LOCAL_SENTINEL = 'local' +_META_SENTINEL = 'meta' + CONFIG_HOOK_DICT = schema.Map( 'Hook', 'id', @@ -121,7 +123,8 @@ CONFIG_REPO_DICT = schema.Map( schema.Conditional( 'sha', schema.check_string, - condition_key='repo', condition_value=schema.Not(_LOCAL_SENTINEL), + condition_key='repo', + condition_value=schema.NotIn((_LOCAL_SENTINEL, _META_SENTINEL)), ensure_absent=True, ), ) @@ -138,6 +141,10 @@ def is_local_repo(repo_entry): return repo_entry['repo'] == _LOCAL_SENTINEL +def is_meta_repo(repo_entry): + return repo_entry['repo'] == _META_SENTINEL + + class InvalidConfigError(FatalError): pass diff --git a/pre_commit/repository.py b/pre_commit/repository.py index 4d7f0a5a..b0858ba9 100644 --- a/pre_commit/repository.py +++ b/pre_commit/repository.py @@ -5,6 +5,7 @@ import json import logging import os import shutil +import sys from collections import defaultdict import pkg_resources @@ -14,6 +15,7 @@ import pre_commit.constants as C from pre_commit import five from pre_commit import git from pre_commit.clientlib import is_local_repo +from pre_commit.clientlib import is_meta_repo from pre_commit.clientlib import MANIFEST_HOOK_DICT from pre_commit.languages.all import languages from pre_commit.languages.helpers import environment_dir @@ -128,6 +130,8 @@ class Repository(object): def create(cls, config, store): if is_local_repo(config): return LocalRepository(config, store) + elif is_meta_repo(config): + return MetaRepository(config, store) else: return cls(config, store) @@ -242,6 +246,45 @@ class LocalRepository(Repository): return tuple(ret) +class MetaRepository(LocalRepository): + meta_hooks = { + 'test-hook': { + 'name': 'Test Hook', + 'files': '', + 'language': 'system', + 'entry': 'echo "Hello World!"', + 'always_run': True, + }, + } + + @cached_property + def hooks(self): + for hook in self.repo_config['hooks']: + if hook['id'] not in self.meta_hooks: + logger.error( + '`{}` is not a valid meta hook. ' + 'Typo? Perhaps it is introduced in a newer version? ' + 'Often `pre-commit autoupdate` fixes this.'.format( + hook['id'], + ), + ) + exit(1) + + return tuple( + ( + hook['id'], + apply_defaults( + validate( + dict(self.meta_hooks[hook['id']], **hook), + MANIFEST_HOOK_DICT, + ), + MANIFEST_HOOK_DICT, + ), + ) + for hook in self.repo_config['hooks'] + ) + + class _UniqueList(list): def __init__(self): self._set = set() diff --git a/pre_commit/schema.py b/pre_commit/schema.py index e20f74cc..e85c2303 100644 --- a/pre_commit/schema.py +++ b/pre_commit/schema.py @@ -101,6 +101,9 @@ def _check_conditional(self, dct): if isinstance(self.condition_value, Not): op = 'is' cond_val = self.condition_value.val + elif isinstance(self.condition_value, NotIn): + op = 'is any of' + cond_val = self.condition_value.values else: op = 'is not' cond_val = self.condition_value @@ -206,6 +209,14 @@ class Not(object): return other is not MISSING and other != self.val +class NotIn(object): + def __init__(self, values): + self.values = values + + def __eq__(self, other): + return other is not MISSING and other not in self.values + + def check_any(_): pass diff --git a/tests/commands/run_test.py b/tests/commands/run_test.py index d6812ae5..e52716fa 100644 --- a/tests/commands/run_test.py +++ b/tests/commands/run_test.py @@ -645,6 +645,31 @@ def test_local_hook_fails( ) +def test_meta_hook_passes( + cap_out, repo_with_passing_hook, mock_out_store_directory, +): + config = OrderedDict(( + ('repo', 'meta'), + ( + 'hooks', ( + OrderedDict(( + ('id', 'test-hook'), + )), + ), + ), + )) + add_config_to_repo(repo_with_passing_hook, config) + + _test_run( + cap_out, + repo_with_passing_hook, + opts={'verbose': True}, + expected_outputs=[b'Hello World!'], + expected_ret=0, + stage=False, + ) + + @pytest.yield_fixture def modified_config_repo(repo_with_passing_hook): with modify_config(repo_with_passing_hook, commit=False) as config: diff --git a/tests/repository_test.py b/tests/repository_test.py index 37a609ba..263ce1ea 100644 --- a/tests/repository_test.py +++ b/tests/repository_test.py @@ -709,6 +709,18 @@ def test_hook_id_not_present(tempdir_factory, store, fake_log_handler): ) +def test_meta_hook_not_present(store, fake_log_handler): + config = {'repo': 'meta', 'hooks': [{'id': 'i-dont-exist'}]} + repo = Repository.create(config, store) + with pytest.raises(SystemExit): + repo.require_installed() + assert fake_log_handler.handle.call_args[0][0].msg == ( + '`i-dont-exist` is not a valid meta hook. ' + 'Typo? Perhaps it is introduced in a newer version? ' + 'Often `pre-commit autoupdate` fixes this.' + ) + + def test_too_new_version(tempdir_factory, store, fake_log_handler): path = make_repo(tempdir_factory, 'script_hooks_repo') with modify_manifest(path) as manifest: diff --git a/tests/schema_test.py b/tests/schema_test.py index c2ecf0fa..06f28e76 100644 --- a/tests/schema_test.py +++ b/tests/schema_test.py @@ -19,6 +19,7 @@ from pre_commit.schema import load_from_filename from pre_commit.schema import Map from pre_commit.schema import MISSING from pre_commit.schema import Not +from pre_commit.schema import NotIn from pre_commit.schema import Optional from pre_commit.schema import OptionalNoDefault from pre_commit.schema import remove_defaults @@ -107,6 +108,16 @@ def test_not(val, expected): assert (compared == val) is expected +@pytest.mark.parametrize( + ('values', 'expected'), + (('bar', True), ('foo', False), (MISSING, False)), +) +def test_not_in(values, expected): + compared = NotIn(('baz', 'foo')) + assert (values == compared) is expected + assert (compared == values) is expected + + trivial_array_schema = Array(Map('foo', 'id')) @@ -196,6 +207,13 @@ map_conditional_absent_not = Map( condition_key='key', condition_value=Not(True), ensure_absent=True, ), ) +map_conditional_absent_not_in = Map( + 'foo', 'key', + Conditional( + 'key2', check_bool, + condition_key='key', condition_value=NotIn((1, 2)), ensure_absent=True, + ), +) @pytest.mark.parametrize('schema', (map_conditional, map_conditional_not)) @@ -248,6 +266,19 @@ def test_ensure_absent_conditional_not(): ) +def test_ensure_absent_conditional_not_in(): + with pytest.raises(ValidationError) as excinfo: + validate({'key': 1, 'key2': True}, map_conditional_absent_not_in) + _assert_exception_trace( + excinfo.value, + ( + 'At foo(key=1)', + 'Expected key2 to be absent when key is any of (1, 2), ' + 'found key2: True', + ), + ) + + def test_no_error_conditional_absent(): validate({}, map_conditional_absent) validate({}, map_conditional_absent_not)