Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 3b10ef4

Browse files
authored
Merge pull request #643 from hackedd/meta-hooks
Implement support for meta hooks
2 parents 9e193f7 + 5a8ca2f commit 3b10ef4

15 files changed

Lines changed: 519 additions & 13 deletions

.pre-commit-config.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,7 @@ repos:
2525
sha: v0.6.4
2626
hooks:
2727
- id: add-trailing-comma
28+
- repo: meta
29+
hooks:
30+
- id: check-useless-excludes
31+
- id: check-files-matches-any

pre_commit/clientlib.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ def validate_manifest_main(argv=None):
9898

9999

100100
_LOCAL_SENTINEL = 'local'
101+
_META_SENTINEL = 'meta'
102+
101103
CONFIG_HOOK_DICT = schema.Map(
102104
'Hook', 'id',
103105

@@ -121,7 +123,8 @@ def validate_manifest_main(argv=None):
121123

122124
schema.Conditional(
123125
'sha', schema.check_string,
124-
condition_key='repo', condition_value=schema.Not(_LOCAL_SENTINEL),
126+
condition_key='repo',
127+
condition_value=schema.NotIn(_LOCAL_SENTINEL, _META_SENTINEL),
125128
ensure_absent=True,
126129
),
127130
)
@@ -138,6 +141,10 @@ def is_local_repo(repo_entry):
138141
return repo_entry['repo'] == _LOCAL_SENTINEL
139142

140143

144+
def is_meta_repo(repo_entry):
145+
return repo_entry['repo'] == _META_SENTINEL
146+
147+
141148
class InvalidConfigError(FatalError):
142149
pass
143150

pre_commit/commands/autoupdate.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from pre_commit import output
1212
from pre_commit.clientlib import CONFIG_SCHEMA
1313
from pre_commit.clientlib import is_local_repo
14+
from pre_commit.clientlib import is_meta_repo
1415
from pre_commit.clientlib import load_config
1516
from pre_commit.commands.migrate_config import migrate_config
1617
from pre_commit.repository import Repository
@@ -115,7 +116,7 @@ def autoupdate(runner, tags_only):
115116
input_config = load_config(runner.config_file_path)
116117

117118
for repo_config in input_config['repos']:
118-
if is_local_repo(repo_config):
119+
if is_local_repo(repo_config) or is_meta_repo(repo_config):
119120
output_repos.append(repo_config)
120121
continue
121122
output.write('Updating {}...'.format(repo_config['repo']))

pre_commit/meta_hooks/__init__.py

Whitespace-only changes.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import argparse
2+
3+
import pre_commit.constants as C
4+
from pre_commit import git
5+
from pre_commit.commands.run import _filter_by_include_exclude
6+
from pre_commit.commands.run import _filter_by_types
7+
from pre_commit.runner import Runner
8+
9+
10+
def check_all_hooks_match_files(config_file):
11+
runner = Runner.create(config_file)
12+
files = git.get_all_files()
13+
retv = 0
14+
15+
for repo in runner.repositories:
16+
for hook_id, hook in repo.hooks:
17+
include, exclude = hook['files'], hook['exclude']
18+
filtered = _filter_by_include_exclude(files, include, exclude)
19+
types, exclude_types = hook['types'], hook['exclude_types']
20+
filtered = _filter_by_types(filtered, types, exclude_types)
21+
if not filtered:
22+
print('{} does not apply to this repository'.format(hook_id))
23+
retv = 1
24+
25+
return retv
26+
27+
28+
def main(argv=None):
29+
parser = argparse.ArgumentParser()
30+
parser.add_argument('filenames', nargs='*', default=[C.CONFIG_FILE])
31+
args = parser.parse_args(argv)
32+
33+
retv = 0
34+
for filename in args.filenames:
35+
retv |= check_all_hooks_match_files(filename)
36+
return retv
37+
38+
39+
if __name__ == '__main__':
40+
exit(main())
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from __future__ import print_function
2+
3+
import argparse
4+
import re
5+
6+
import pre_commit.constants as C
7+
from pre_commit import git
8+
from pre_commit.clientlib import load_config
9+
from pre_commit.clientlib import MANIFEST_HOOK_DICT
10+
from pre_commit.schema import apply_defaults
11+
12+
13+
def exclude_matches_any(filenames, include, exclude):
14+
if exclude == '^$':
15+
return True
16+
include_re, exclude_re = re.compile(include), re.compile(exclude)
17+
for filename in filenames:
18+
if include_re.search(filename) and exclude_re.search(filename):
19+
return True
20+
return False
21+
22+
23+
def check_useless_excludes(config_file):
24+
config = load_config(config_file)
25+
files = git.get_all_files()
26+
retv = 0
27+
28+
exclude = config['exclude']
29+
if not exclude_matches_any(files, '', exclude):
30+
print(
31+
'The global exclude pattern {!r} does not match any files'
32+
.format(exclude),
33+
)
34+
retv = 1
35+
36+
for repo in config['repos']:
37+
for hook in repo['hooks']:
38+
# Not actually a manifest dict, but this more accurately reflects
39+
# the defaults applied during runtime
40+
hook = apply_defaults(hook, MANIFEST_HOOK_DICT)
41+
include, exclude = hook['files'], hook['exclude']
42+
if not exclude_matches_any(files, include, exclude):
43+
print(
44+
'The exclude pattern {!r} for {} does not match any files'
45+
.format(exclude, hook['id']),
46+
)
47+
retv = 1
48+
49+
return retv
50+
51+
52+
def main(argv=None):
53+
parser = argparse.ArgumentParser()
54+
parser.add_argument('filenames', nargs='*', default=[C.CONFIG_FILE])
55+
args = parser.parse_args(argv)
56+
57+
retv = 0
58+
for filename in args.filenames:
59+
retv |= check_useless_excludes(filename)
60+
return retv
61+
62+
63+
if __name__ == '__main__':
64+
exit(main())

pre_commit/repository.py

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
import json
55
import logging
66
import os
7+
import pipes
78
import shutil
9+
import sys
810
from collections import defaultdict
911

1012
import pkg_resources
@@ -14,6 +16,7 @@
1416
from pre_commit import five
1517
from pre_commit import git
1618
from pre_commit.clientlib import is_local_repo
19+
from pre_commit.clientlib import is_meta_repo
1720
from pre_commit.clientlib import load_manifest
1821
from pre_commit.clientlib import MANIFEST_HOOK_DICT
1922
from pre_commit.languages.all import languages
@@ -125,6 +128,12 @@ def _hook(*hook_dicts):
125128
return ret
126129

127130

131+
def _hook_from_manifest_dct(dct):
132+
dct = validate(apply_defaults(dct, MANIFEST_HOOK_DICT), MANIFEST_HOOK_DICT)
133+
dct = _hook(dct)
134+
return dct
135+
136+
128137
class Repository(object):
129138
def __init__(self, repo_config, store):
130139
self.repo_config = repo_config
@@ -135,6 +144,8 @@ def __init__(self, repo_config, store):
135144
def create(cls, config, store):
136145
if is_local_repo(config):
137146
return LocalRepository(config, store)
147+
elif is_meta_repo(config):
148+
return MetaRepository(config, store)
138149
else:
139150
return cls(config, store)
140151

@@ -221,14 +232,8 @@ def manifest(self):
221232

222233
@cached_property
223234
def hooks(self):
224-
def _from_manifest_dct(dct):
225-
dct = validate(dct, MANIFEST_HOOK_DICT)
226-
dct = apply_defaults(dct, MANIFEST_HOOK_DICT)
227-
dct = _hook(dct)
228-
return dct
229-
230235
return tuple(
231-
(hook['id'], _from_manifest_dct(hook))
236+
(hook['id'], _hook_from_manifest_dct(hook))
232237
for hook in self.repo_config['hooks']
233238
)
234239

@@ -246,6 +251,60 @@ def _venvs(self):
246251
return tuple(ret)
247252

248253

254+
class MetaRepository(LocalRepository):
255+
@cached_property
256+
def manifest_hooks(self):
257+
# The hooks are imported here to prevent circular imports.
258+
from pre_commit.meta_hooks import check_files_matches_any
259+
from pre_commit.meta_hooks import check_useless_excludes
260+
261+
def _make_entry(mod):
262+
"""the hook `entry` is passed through `shlex.split()` by the
263+
command runner, so to prevent issues with spaces and backslashes
264+
(on Windows) it must be quoted here.
265+
"""
266+
return '{} -m {}'.format(pipes.quote(sys.executable), mod.__name__)
267+
268+
meta_hooks = [
269+
{
270+
'id': 'check-files-matches-any',
271+
'name': 'Check hooks match any files',
272+
'files': '.pre-commit-config.yaml',
273+
'language': 'system',
274+
'entry': _make_entry(check_files_matches_any),
275+
},
276+
{
277+
'id': 'check-useless-excludes',
278+
'name': 'Check for useless excludes',
279+
'files': '.pre-commit-config.yaml',
280+
'language': 'system',
281+
'entry': _make_entry(check_useless_excludes),
282+
},
283+
]
284+
285+
return {
286+
hook['id']: _hook_from_manifest_dct(hook)
287+
for hook in meta_hooks
288+
}
289+
290+
@cached_property
291+
def hooks(self):
292+
for hook in self.repo_config['hooks']:
293+
if hook['id'] not in self.manifest_hooks:
294+
logger.error(
295+
'`{}` is not a valid meta hook. '
296+
'Typo? Perhaps it is introduced in a newer version? '
297+
'Often `pip install --upgrade pre-commit` fixes this.'
298+
.format(hook['id']),
299+
)
300+
exit(1)
301+
302+
return tuple(
303+
(hook['id'], _hook(self.manifest_hooks[hook['id']], hook))
304+
for hook in self.repo_config['hooks']
305+
)
306+
307+
249308
class _UniqueList(list):
250309
def __init__(self):
251310
self._set = set()

pre_commit/schema.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ def _check_conditional(self, dct):
101101
if isinstance(self.condition_value, Not):
102102
op = 'is'
103103
cond_val = self.condition_value.val
104+
elif isinstance(self.condition_value, NotIn):
105+
op = 'is any of'
106+
cond_val = self.condition_value.values
104107
else:
105108
op = 'is not'
106109
cond_val = self.condition_value
@@ -198,14 +201,19 @@ def remove_defaults(self, v):
198201
return [remove_defaults(val, self.of) for val in v]
199202

200203

201-
class Not(object):
202-
def __init__(self, val):
203-
self.val = val
204-
204+
class Not(collections.namedtuple('Not', ('val',))):
205205
def __eq__(self, other):
206206
return other is not MISSING and other != self.val
207207

208208

209+
class NotIn(collections.namedtuple('NotIn', ('values',))):
210+
def __new__(cls, *values):
211+
return super(NotIn, cls).__new__(cls, values=values)
212+
213+
def __eq__(self, other):
214+
return other is not MISSING and other not in self.values
215+
216+
209217
def check_any(_):
210218
pass
211219

tests/commands/autoupdate_test.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,24 @@ def test_autoupdate_local_hooks_with_out_of_date_repo(
295295
assert new_config_writen['repos'][0] == local_config
296296

297297

298+
def test_autoupdate_meta_hooks(tmpdir, capsys):
299+
cfg = tmpdir.join(C.CONFIG_FILE)
300+
cfg.write(
301+
'repos:\n'
302+
'- repo: meta\n'
303+
' hooks:\n'
304+
' - id: check-useless-excludes\n',
305+
)
306+
ret = autoupdate(Runner(tmpdir.strpath, C.CONFIG_FILE), tags_only=True)
307+
assert ret == 0
308+
assert cfg.read() == (
309+
'repos:\n'
310+
'- repo: meta\n'
311+
' hooks:\n'
312+
' - id: check-useless-excludes\n'
313+
)
314+
315+
298316
def test_updates_old_format_to_new_format(tmpdir, capsys):
299317
cfg = tmpdir.join(C.CONFIG_FILE)
300318
cfg.write(

tests/commands/run_test.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,31 @@ def test_local_hook_fails(
645645
)
646646

647647

648+
def test_meta_hook_passes(
649+
cap_out, repo_with_passing_hook, mock_out_store_directory,
650+
):
651+
config = OrderedDict((
652+
('repo', 'meta'),
653+
(
654+
'hooks', (
655+
OrderedDict((
656+
('id', 'check-useless-excludes'),
657+
)),
658+
),
659+
),
660+
))
661+
add_config_to_repo(repo_with_passing_hook, config)
662+
663+
_test_run(
664+
cap_out,
665+
repo_with_passing_hook,
666+
opts={},
667+
expected_outputs=[b'Check for useless excludes'],
668+
expected_ret=0,
669+
stage=False,
670+
)
671+
672+
648673
@pytest.yield_fixture
649674
def modified_config_repo(repo_with_passing_hook):
650675
with modify_config(repo_with_passing_hook, commit=False) as config:

0 commit comments

Comments
 (0)