summaryrefslogtreecommitdiffstats
path: root/pre_commit/clientlib.py
diff options
context:
space:
mode:
Diffstat (limited to 'pre_commit/clientlib.py')
-rw-r--r--pre_commit/clientlib.py405
1 files changed, 405 insertions, 0 deletions
diff --git a/pre_commit/clientlib.py b/pre_commit/clientlib.py
new file mode 100644
index 0000000..a49465e
--- /dev/null
+++ b/pre_commit/clientlib.py
@@ -0,0 +1,405 @@
+from __future__ import annotations
+
+import functools
+import logging
+import re
+import shlex
+import sys
+from collections.abc import Sequence
+from typing import Any
+from typing import NamedTuple
+
+import cfgv
+from identify.identify import ALL_TAGS
+
+import pre_commit.constants as C
+from pre_commit.all_languages import language_names
+from pre_commit.errors import FatalError
+from pre_commit.yaml import yaml_load
+
+logger = logging.getLogger('pre_commit')
+
+check_string_regex = cfgv.check_and(cfgv.check_string, cfgv.check_regex)
+
+HOOK_TYPES = (
+ 'commit-msg',
+ 'post-checkout',
+ 'post-commit',
+ 'post-merge',
+ 'post-rewrite',
+ 'pre-commit',
+ 'pre-merge-commit',
+ 'pre-push',
+ 'pre-rebase',
+ 'prepare-commit-msg',
+)
+# `manual` is not invoked by any installed git hook. See #719
+STAGES = (*HOOK_TYPES, 'manual')
+
+
+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 parse_version(s: str) -> tuple[int, ...]:
+ """poor man's version comparison"""
+ return tuple(int(p) for p in s.split('.'))
+
+
+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`.',
+ )
+
+
+_STAGES = {
+ 'commit': 'pre-commit',
+ 'merge-commit': 'pre-merge-commit',
+ 'push': 'pre-push',
+}
+
+
+def transform_stage(stage: str) -> str:
+ return _STAGES.get(stage, stage)
+
+
+class StagesMigrationNoDefault(NamedTuple):
+ key: str
+ default: Sequence[str]
+
+ def check(self, dct: dict[str, Any]) -> None:
+ if self.key not in dct:
+ return
+
+ val = dct[self.key]
+ cfgv.check_array(cfgv.check_any)(val)
+
+ val = [transform_stage(v) for v in val]
+ cfgv.check_array(cfgv.check_one_of(STAGES))(val)
+
+ def apply_default(self, dct: dict[str, Any]) -> None:
+ if self.key not in dct:
+ return
+ dct[self.key] = [transform_stage(v) for v in dct[self.key]]
+
+ def remove_default(self, dct: dict[str, Any]) -> None:
+ raise NotImplementedError
+
+
+class StagesMigration(StagesMigrationNoDefault):
+ def apply_default(self, dct: dict[str, Any]) -> None:
+ dct.setdefault(self.key, self.default)
+ super().apply_default(dct)
+
+
+MANIFEST_HOOK_DICT = cfgv.Map(
+ 'Hook', 'id',
+
+ # check first in case it uses some newer, incompatible feature
+ cfgv.Optional(
+ 'minimum_pre_commit_version',
+ cfgv.check_and(cfgv.check_string, check_min_version),
+ '0',
+ ),
+
+ 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(language_names)),
+ 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('types_or', cfgv.check_array(check_type_tag), []),
+ 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('fail_fast', 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('require_serial', cfgv.check_bool, False),
+ StagesMigration('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,
+)
+
+
+LOCAL = 'local'
+META = 'meta'
+
+
+class WarnMutableRev(cfgv.Conditional):
+ def check(self, dct: dict[str, Any]) -> None:
+ super().check(dct)
+
+ if self.key in dct:
+ rev = dct[self.key]
+
+ if '.' not in rev and not re.match(r'^[a-fA-F0-9]+$', rev):
+ logger.warning(
+ f'The {self.key!r} field of repo {dct["repo"]!r} '
+ f'appears to be a mutable reference '
+ f'(moving tag / branch). Mutable references are never '
+ f'updated after first install and are not supported. '
+ f'See https://pre-commit.com/#using-the-latest-version-for-a-repository ' # noqa: E501
+ f'for more details. '
+ f'Hint: `pre-commit autoupdate` often fixes this.',
+ )
+
+
+class OptionalSensibleRegexAtHook(cfgv.OptionalNoDefault):
+ def check(self, dct: dict[str, Any]) -> None:
+ super().check(dct)
+
+ if '/*' in dct.get(self.key, ''):
+ logger.warning(
+ f'The {self.key!r} field in hook {dct.get("id")!r} is a '
+ f"regex, not a glob -- matching '/*' probably isn't what you "
+ f'want here',
+ )
+ for fwd_slash_re in (r'[\\/]', r'[\/]', r'[/\\]'):
+ if fwd_slash_re in dct.get(self.key, ''):
+ logger.warning(
+ fr'pre-commit normalizes slashes in the {self.key!r} '
+ fr'field in hook {dct.get("id")!r} to forward slashes, '
+ fr'so you can use / instead of {fwd_slash_re}',
+ )
+
+
+class OptionalSensibleRegexAtTop(cfgv.OptionalNoDefault):
+ def check(self, dct: dict[str, Any]) -> None:
+ super().check(dct)
+
+ if '/*' in dct.get(self.key, ''):
+ logger.warning(
+ f'The top-level {self.key!r} field is a regex, not a glob -- '
+ f"matching '/*' probably isn't what you want here",
+ )
+ for fwd_slash_re in (r'[\\/]', r'[\/]', r'[/\\]'):
+ if fwd_slash_re in dct.get(self.key, ''):
+ logger.warning(
+ fr'pre-commit normalizes the slashes in the top-level '
+ fr'{self.key!r} field to forward slashes, so you '
+ fr'can use / instead of {fwd_slash_re}',
+ )
+
+
+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', f'^{re.escape(C.CONFIG_FILE)}$'),
+ ('entry', _entry('check_hooks_apply')),
+ ),
+ ),
+ (
+ 'check-useless-excludes', (
+ ('name', 'Check for useless excludes'),
+ ('files', f'^{re.escape(C.CONFIG_FILE)}$'),
+ ('entry', _entry('check_useless_excludes')),
+ ),
+ ),
+ (
+ 'identity', (
+ ('name', 'identity'),
+ ('verbose', True),
+ ('entry', _entry('identity')),
+ ),
+ ),
+)
+
+
+class NotAllowed(cfgv.OptionalNoDefault):
+ def check(self, dct: dict[str, Any]) -> None:
+ if self.key in dct:
+ raise cfgv.ValidationError(f'{self.key!r} cannot be overridden')
+
+
+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'),
+ # entry cannot be overridden
+ NotAllowed('entry', cfgv.check_any),
+ *(
+ # 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'
+ if item.key != 'stages'
+ ),
+ StagesMigrationNoDefault('stages', []),
+ OptionalSensibleRegexAtHook('files', cfgv.check_string),
+ OptionalSensibleRegexAtHook('exclude', cfgv.check_string),
+)
+LOCAL_HOOK_DICT = cfgv.Map(
+ 'Hook', 'id',
+
+ *MANIFEST_HOOK_DICT.items,
+
+ OptionalSensibleRegexAtHook('files', cfgv.check_string),
+ OptionalSensibleRegexAtHook('exclude', cfgv.check_string),
+)
+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(LOCAL_HOOK_DICT),
+ 'repo', LOCAL,
+ ),
+ cfgv.ConditionalRecurse(
+ 'hooks', cfgv.Array(META_HOOK_DICT),
+ 'repo', META,
+ ),
+
+ WarnMutableRev(
+ 'rev', cfgv.check_string,
+ condition_key='repo',
+ condition_value=cfgv.NotIn(LOCAL, META),
+ ensure_absent=True,
+ ),
+ cfgv.WarnAdditionalKeys(('repo', 'rev', 'hooks'), warn_unknown_keys_repo),
+)
+DEFAULT_LANGUAGE_VERSION = cfgv.Map(
+ 'DefaultLanguageVersion', None,
+ cfgv.NoAdditionalKeys(language_names),
+ *(cfgv.Optional(x, cfgv.check_string, C.DEFAULT) for x in language_names),
+)
+CONFIG_SCHEMA = cfgv.Map(
+ 'Config', None,
+
+ # check first in case it uses some newer, incompatible feature
+ cfgv.Optional(
+ 'minimum_pre_commit_version',
+ cfgv.check_and(cfgv.check_string, check_min_version),
+ '0',
+ ),
+
+ cfgv.RequiredRecurse('repos', cfgv.Array(CONFIG_REPO_DICT)),
+ cfgv.Optional(
+ 'default_install_hook_types',
+ cfgv.check_array(cfgv.check_one_of(HOOK_TYPES)),
+ ['pre-commit'],
+ ),
+ cfgv.OptionalRecurse(
+ 'default_language_version', DEFAULT_LANGUAGE_VERSION, {},
+ ),
+ StagesMigration('default_stages', STAGES),
+ cfgv.Optional('files', check_string_regex, ''),
+ cfgv.Optional('exclude', check_string_regex, '^$'),
+ cfgv.Optional('fail_fast', cfgv.check_bool, False),
+ cfgv.WarnAdditionalKeys(
+ (
+ 'repos',
+ 'default_install_hook_types',
+ 'default_language_version',
+ 'default_stages',
+ 'files',
+ 'exclude',
+ 'fail_fast',
+ 'minimum_pre_commit_version',
+ 'ci',
+ ),
+ warn_unknown_keys_root,
+ ),
+ OptionalSensibleRegexAtTop('files', cfgv.check_string),
+ OptionalSensibleRegexAtTop('exclude', cfgv.check_string),
+
+ # do not warn about configuration for pre-commit.ci
+ cfgv.OptionalNoDefault('ci', cfgv.check_type(dict)),
+)
+
+
+class InvalidConfigError(FatalError):
+ pass
+
+
+load_config = functools.partial(
+ cfgv.load_from_filename,
+ schema=CONFIG_SCHEMA,
+ load_strategy=yaml_load,
+ exc_tp=InvalidConfigError,
+)