diff options
Diffstat (limited to 'pre_commit/clientlib.py')
-rw-r--r-- | pre_commit/clientlib.py | 317 |
1 files changed, 317 insertions, 0 deletions
diff --git a/pre_commit/clientlib.py b/pre_commit/clientlib.py new file mode 100644 index 0000000..56ec0dd --- /dev/null +++ b/pre_commit/clientlib.py @@ -0,0 +1,317 @@ +import argparse +import functools +import logging +import shlex +import sys +from typing import Any +from typing import Dict +from typing import Optional +from typing import Sequence + +import cfgv +from identify.identify import ALL_TAGS + +import pre_commit.constants as C +from pre_commit.error_handler import FatalError +from pre_commit.languages.all import all_languages +from pre_commit.util import parse_version +from pre_commit.util import yaml_load + +logger = logging.getLogger('pre_commit') + +check_string_regex = cfgv.check_and(cfgv.check_string, cfgv.check_regex) + + +def check_type_tag(tag: str) -> None: + if tag not in ALL_TAGS: + raise cfgv.ValidationError( + f'Type tag {tag!r} is not recognized. ' + f'Try upgrading identify and pre-commit?', + ) + + +def check_min_version(version: str) -> None: + if parse_version(version) > parse_version(C.VERSION): + raise cfgv.ValidationError( + f'pre-commit version {version} is required but version ' + f'{C.VERSION} is installed. ' + f'Perhaps run `pip install --upgrade pre-commit`.', + ) + + +def _make_argparser(filenames_help: str) -> argparse.ArgumentParser: + parser = argparse.ArgumentParser() + parser.add_argument('filenames', nargs='*', help=filenames_help) + parser.add_argument('-V', '--version', action='version', version=C.VERSION) + return parser + + +MANIFEST_HOOK_DICT = cfgv.Map( + 'Hook', 'id', + + cfgv.Required('id', cfgv.check_string), + cfgv.Required('name', cfgv.check_string), + cfgv.Required('entry', cfgv.check_string), + cfgv.Required('language', cfgv.check_one_of(all_languages)), + cfgv.Optional('alias', cfgv.check_string, ''), + + cfgv.Optional('files', check_string_regex, ''), + cfgv.Optional('exclude', check_string_regex, '^$'), + cfgv.Optional('types', cfgv.check_array(check_type_tag), ['file']), + cfgv.Optional('exclude_types', cfgv.check_array(check_type_tag), []), + + cfgv.Optional( + 'additional_dependencies', cfgv.check_array(cfgv.check_string), [], + ), + cfgv.Optional('args', cfgv.check_array(cfgv.check_string), []), + cfgv.Optional('always_run', cfgv.check_bool, False), + cfgv.Optional('pass_filenames', cfgv.check_bool, True), + cfgv.Optional('description', cfgv.check_string, ''), + cfgv.Optional('language_version', cfgv.check_string, C.DEFAULT), + cfgv.Optional('log_file', cfgv.check_string, ''), + cfgv.Optional('minimum_pre_commit_version', cfgv.check_string, '0'), + cfgv.Optional('require_serial', cfgv.check_bool, False), + cfgv.Optional('stages', cfgv.check_array(cfgv.check_one_of(C.STAGES)), []), + cfgv.Optional('verbose', cfgv.check_bool, False), +) +MANIFEST_SCHEMA = cfgv.Array(MANIFEST_HOOK_DICT) + + +class InvalidManifestError(FatalError): + pass + + +load_manifest = functools.partial( + cfgv.load_from_filename, + schema=MANIFEST_SCHEMA, + load_strategy=yaml_load, + exc_tp=InvalidManifestError, +) + + +def validate_manifest_main(argv: Optional[Sequence[str]] = None) -> int: + parser = _make_argparser('Manifest filenames.') + args = parser.parse_args(argv) + ret = 0 + for filename in args.filenames: + try: + load_manifest(filename) + except InvalidManifestError as e: + print(e) + ret = 1 + return ret + + +LOCAL = 'local' +META = 'meta' + + +class MigrateShaToRev: + key = 'rev' + + @staticmethod + def _cond(key: str) -> cfgv.Conditional: + return cfgv.Conditional( + key, cfgv.check_string, + condition_key='repo', + condition_value=cfgv.NotIn(LOCAL, META), + ensure_absent=True, + ) + + def check(self, dct: Dict[str, Any]) -> None: + if dct.get('repo') in {LOCAL, META}: + self._cond('rev').check(dct) + self._cond('sha').check(dct) + elif 'sha' in dct and 'rev' in dct: + raise cfgv.ValidationError('Cannot specify both sha and rev') + elif 'sha' in dct: + self._cond('sha').check(dct) + else: + self._cond('rev').check(dct) + + def apply_default(self, dct: Dict[str, Any]) -> None: + if 'sha' in dct: + dct['rev'] = dct.pop('sha') + + remove_default = cfgv.Required.remove_default + + +def _entry(modname: str) -> str: + """the hook `entry` is passed through `shlex.split()` by the command + runner, so to prevent issues with spaces and backslashes (on Windows) + it must be quoted here. + """ + return f'{shlex.quote(sys.executable)} -m pre_commit.meta_hooks.{modname}' + + +def warn_unknown_keys_root( + extra: Sequence[str], + orig_keys: Sequence[str], + dct: Dict[str, str], +) -> None: + logger.warning(f'Unexpected key(s) present at root: {", ".join(extra)}') + + +def warn_unknown_keys_repo( + extra: Sequence[str], + orig_keys: Sequence[str], + dct: Dict[str, str], +) -> None: + logger.warning( + f'Unexpected key(s) present on {dct["repo"]}: {", ".join(extra)}', + ) + + +_meta = ( + ( + 'check-hooks-apply', ( + ('name', 'Check hooks apply to the repository'), + ('files', C.CONFIG_FILE), + ('entry', _entry('check_hooks_apply')), + ), + ), + ( + 'check-useless-excludes', ( + ('name', 'Check for useless excludes'), + ('files', C.CONFIG_FILE), + ('entry', _entry('check_useless_excludes')), + ), + ), + ( + 'identity', ( + ('name', 'identity'), + ('verbose', True), + ('entry', _entry('identity')), + ), + ), +) + +META_HOOK_DICT = cfgv.Map( + 'Hook', 'id', + cfgv.Required('id', cfgv.check_string), + cfgv.Required('id', cfgv.check_one_of(tuple(k for k, _ in _meta))), + # language must be system + cfgv.Optional('language', cfgv.check_one_of({'system'}), 'system'), + *( + # default to the hook definition for the meta hooks + cfgv.ConditionalOptional(key, cfgv.check_any, value, 'id', hook_id) + for hook_id, values in _meta + for key, value in values + ), + *( + # default to the "manifest" parsing + cfgv.OptionalNoDefault(item.key, item.check_fn) + # these will always be defaulted above + if item.key in {'name', 'language', 'entry'} else + item + for item in MANIFEST_HOOK_DICT.items + ), +) +CONFIG_HOOK_DICT = cfgv.Map( + 'Hook', 'id', + + cfgv.Required('id', cfgv.check_string), + + # All keys in manifest hook dict are valid in a config hook dict, but + # are optional. + # No defaults are provided here as the config is merged on top of the + # manifest. + *( + cfgv.OptionalNoDefault(item.key, item.check_fn) + for item in MANIFEST_HOOK_DICT.items + if item.key != 'id' + ), +) +CONFIG_REPO_DICT = cfgv.Map( + 'Repository', 'repo', + + cfgv.Required('repo', cfgv.check_string), + + cfgv.ConditionalRecurse( + 'hooks', cfgv.Array(CONFIG_HOOK_DICT), + 'repo', cfgv.NotIn(LOCAL, META), + ), + cfgv.ConditionalRecurse( + 'hooks', cfgv.Array(MANIFEST_HOOK_DICT), + 'repo', LOCAL, + ), + cfgv.ConditionalRecurse( + 'hooks', cfgv.Array(META_HOOK_DICT), + 'repo', META, + ), + + MigrateShaToRev(), + cfgv.WarnAdditionalKeys(('repo', 'rev', 'hooks'), warn_unknown_keys_repo), +) +DEFAULT_LANGUAGE_VERSION = cfgv.Map( + 'DefaultLanguageVersion', None, + cfgv.NoAdditionalKeys(all_languages), + *(cfgv.Optional(x, cfgv.check_string, C.DEFAULT) for x in all_languages), +) +CONFIG_SCHEMA = cfgv.Map( + 'Config', None, + + cfgv.RequiredRecurse('repos', cfgv.Array(CONFIG_REPO_DICT)), + cfgv.OptionalRecurse( + 'default_language_version', DEFAULT_LANGUAGE_VERSION, {}, + ), + cfgv.Optional( + 'default_stages', + cfgv.check_array(cfgv.check_one_of(C.STAGES)), + C.STAGES, + ), + cfgv.Optional('files', check_string_regex, ''), + cfgv.Optional('exclude', check_string_regex, '^$'), + cfgv.Optional('fail_fast', cfgv.check_bool, False), + cfgv.Optional( + 'minimum_pre_commit_version', + cfgv.check_and(cfgv.check_string, check_min_version), + '0', + ), + cfgv.WarnAdditionalKeys( + ( + 'repos', + 'default_language_version', + 'default_stages', + 'files', + 'exclude', + 'fail_fast', + 'minimum_pre_commit_version', + ), + warn_unknown_keys_root, + ), +) + + +class InvalidConfigError(FatalError): + pass + + +def ordered_load_normalize_legacy_config(contents: str) -> Dict[str, Any]: + data = yaml_load(contents) + if isinstance(data, list): + # TODO: Once happy, issue a deprecation warning and instructions + return {'repos': data} + else: + return data + + +load_config = functools.partial( + cfgv.load_from_filename, + schema=CONFIG_SCHEMA, + load_strategy=ordered_load_normalize_legacy_config, + exc_tp=InvalidConfigError, +) + + +def validate_config_main(argv: Optional[Sequence[str]] = None) -> int: + parser = _make_argparser('Config filenames.') + args = parser.parse_args(argv) + ret = 0 + for filename in args.filenames: + try: + load_config(filename) + except InvalidConfigError as e: + print(e) + ret = 1 + return ret |