Skip to content

Commit bdf6879

Browse files
committed
add pre-commit hazmat
1 parent 9c7ea88 commit bdf6879

File tree

7 files changed

+243
-2
lines changed

7 files changed

+243
-2
lines changed

pre_commit/commands/hazmat.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import subprocess
5+
from collections.abc import Sequence
6+
7+
from pre_commit.parse_shebang import normalize_cmd
8+
9+
10+
def add_parsers(parser: argparse.ArgumentParser) -> None:
11+
subparsers = parser.add_subparsers(dest='tool')
12+
13+
cd_parser = subparsers.add_parser(
14+
'cd', help='cd to a subdir and run the command',
15+
)
16+
cd_parser.add_argument('subdir')
17+
cd_parser.add_argument('cmd', nargs=argparse.REMAINDER)
18+
19+
ignore_exit_code_parser = subparsers.add_parser(
20+
'ignore-exit-code', help='run the command but ignore the exit code',
21+
)
22+
ignore_exit_code_parser.add_argument('cmd', nargs=argparse.REMAINDER)
23+
24+
n1_parser = subparsers.add_parser(
25+
'n1', help='run the command once per filename',
26+
)
27+
n1_parser.add_argument('cmd', nargs=argparse.REMAINDER)
28+
29+
30+
def _cmd_filenames(cmd: tuple[str, ...]) -> tuple[
31+
tuple[str, ...],
32+
tuple[str, ...],
33+
]:
34+
for idx, val in enumerate(reversed(cmd)):
35+
if val == '--':
36+
split = len(cmd) - idx
37+
break
38+
else:
39+
raise SystemExit('hazmat entry must end with `--`')
40+
41+
return cmd[:split - 1], cmd[split:]
42+
43+
44+
def cd(subdir: str, cmd: tuple[str, ...]) -> int:
45+
cmd, filenames = _cmd_filenames(cmd)
46+
47+
prefix = f'{subdir}/'
48+
new_filenames = []
49+
for filename in filenames:
50+
if not filename.startswith(prefix):
51+
raise SystemExit(f'unexpected file without {prefix=}: {filename}')
52+
else:
53+
new_filenames.append(filename.removeprefix(prefix))
54+
55+
cmd = normalize_cmd(cmd)
56+
return subprocess.call((*cmd, *new_filenames), cwd=subdir)
57+
58+
59+
def ignore_exit_code(cmd: tuple[str, ...]) -> int:
60+
cmd = normalize_cmd(cmd)
61+
subprocess.call(cmd)
62+
return 0
63+
64+
65+
def n1(cmd: tuple[str, ...]) -> int:
66+
cmd, filenames = _cmd_filenames(cmd)
67+
cmd = normalize_cmd(cmd)
68+
ret = 0
69+
for filename in filenames:
70+
ret |= subprocess.call((*cmd, filename))
71+
return ret
72+
73+
74+
def impl(args: argparse.Namespace) -> int:
75+
args.cmd = tuple(args.cmd)
76+
if args.tool == 'cd':
77+
return cd(args.subdir, args.cmd)
78+
elif args.tool == 'ignore-exit-code':
79+
return ignore_exit_code(args.cmd)
80+
elif args.tool == 'n1':
81+
return n1(args.cmd)
82+
else:
83+
raise NotImplementedError(f'unexpected tool: {args.tool}')
84+
85+
86+
def main(argv: Sequence[str] | None = None) -> int:
87+
parser = argparse.ArgumentParser()
88+
add_parsers(parser)
89+
args = parser.parse_args(argv)
90+
91+
return impl(args)
92+
93+
94+
if __name__ == '__main__':
95+
raise SystemExit(main())

pre_commit/lang_base.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import random
66
import re
77
import shlex
8+
import sys
89
from collections.abc import Generator
910
from collections.abc import Sequence
1011
from typing import Any
@@ -171,7 +172,10 @@ def run_xargs(
171172

172173

173174
def hook_cmd(entry: str, args: Sequence[str]) -> tuple[str, ...]:
174-
return (*shlex.split(entry), *args)
175+
cmd = shlex.split(entry)
176+
if cmd[:2] == ['pre-commit', 'hazmat']:
177+
cmd = [sys.executable, '-m', 'pre_commit.commands.hazmat', *cmd[2:]]
178+
return (*cmd, *args)
175179

176180

177181
def basic_run_hook(

pre_commit/main.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from pre_commit import clientlib
1111
from pre_commit import git
1212
from pre_commit.color import add_color_option
13+
from pre_commit.commands import hazmat
1314
from pre_commit.commands.autoupdate import autoupdate
1415
from pre_commit.commands.clean import clean
1516
from pre_commit.commands.gc import gc
@@ -41,7 +42,7 @@
4142
os.environ.pop('PYTHONEXECUTABLE', None)
4243

4344
COMMANDS_NO_GIT = {
44-
'clean', 'gc', 'init-templatedir', 'sample-config',
45+
'clean', 'gc', 'hazmat', 'init-templatedir', 'sample-config',
4546
'validate-config', 'validate-manifest',
4647
}
4748

@@ -245,6 +246,11 @@ def _add_cmd(name: str, *, help: str) -> argparse.ArgumentParser:
245246

246247
_add_cmd('gc', help='Clean unused cached repos.')
247248

249+
hazmat_parser = _add_cmd(
250+
'hazmat', help='Composable tools for rare use in hook `entry`.',
251+
)
252+
hazmat.add_parsers(hazmat_parser)
253+
248254
init_templatedir_parser = _add_cmd(
249255
'init-templatedir',
250256
help=(
@@ -389,6 +395,8 @@ def _add_cmd(name: str, *, help: str) -> argparse.ArgumentParser:
389395
return clean(store)
390396
elif args.command == 'gc':
391397
return gc(store)
398+
elif args.command == 'hazmat':
399+
return hazmat.impl(args)
392400
elif args.command == 'hook-impl':
393401
return hook_impl(
394402
store,

tests/commands/hazmat_test.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
5+
import pytest
6+
7+
from pre_commit.commands.hazmat import _cmd_filenames
8+
from pre_commit.commands.hazmat import main
9+
from testing.util import cwd
10+
11+
12+
def test_cmd_filenames_no_dash_dash():
13+
with pytest.raises(SystemExit) as excinfo:
14+
_cmd_filenames(('no', 'dashdash', 'here'))
15+
msg, = excinfo.value.args
16+
assert msg == 'hazmat entry must end with `--`'
17+
18+
19+
def test_cmd_filenames_no_filenames():
20+
cmd, filenames = _cmd_filenames(('hello', 'world', '--'))
21+
assert cmd == ('hello', 'world')
22+
assert filenames == ()
23+
24+
25+
def test_cmd_filenames_some_filenames():
26+
cmd, filenames = _cmd_filenames(('hello', 'world', '--', 'f1', 'f2'))
27+
assert cmd == ('hello', 'world')
28+
assert filenames == ('f1', 'f2')
29+
30+
31+
def test_cmd_filenames_multiple_dashdash():
32+
cmd, filenames = _cmd_filenames(('hello', '--', 'arg', '--', 'f1', 'f2'))
33+
assert cmd == ('hello', '--', 'arg')
34+
assert filenames == ('f1', 'f2')
35+
36+
37+
def test_cd_unexpected_filename():
38+
with pytest.raises(SystemExit) as excinfo:
39+
main(('cd', 'subdir', 'cmd', '--', 'subdir/1', 'not-subdir/2'))
40+
msg, = excinfo.value.args
41+
assert msg == "unexpected file without prefix='subdir/': not-subdir/2"
42+
43+
44+
def _norm(out):
45+
return out.replace('\r\n', '\n')
46+
47+
48+
def test_cd(tmp_path, capfd):
49+
subdir = tmp_path.joinpath('subdir')
50+
subdir.mkdir()
51+
subdir.joinpath('a').write_text('a')
52+
subdir.joinpath('b').write_text('b')
53+
54+
with cwd(tmp_path):
55+
ret = main((
56+
'cd', 'subdir',
57+
sys.executable, '-c',
58+
'import os; print(os.getcwd());'
59+
'import sys; [print(open(f).read()) for f in sys.argv[1:]]',
60+
'--',
61+
'subdir/a', 'subdir/b',
62+
))
63+
64+
assert ret == 0
65+
out, err = capfd.readouterr()
66+
assert _norm(out) == f'{subdir}\na\nb\n'
67+
assert err == ''
68+
69+
70+
def test_ignore_exit_code(capfd):
71+
ret = main((
72+
'ignore-exit-code', sys.executable, '-c', 'raise SystemExit("bye")',
73+
))
74+
assert ret == 0
75+
out, err = capfd.readouterr()
76+
assert out == ''
77+
assert _norm(err) == 'bye\n'
78+
79+
80+
def test_n1(capfd):
81+
ret = main((
82+
'n1', sys.executable, '-c', 'import sys; print(sys.argv[1:])',
83+
'--',
84+
'foo', 'bar', 'baz',
85+
))
86+
assert ret == 0
87+
out, err = capfd.readouterr()
88+
assert _norm(out) == "['foo']\n['bar']\n['baz']\n"
89+
assert err == ''
90+
91+
92+
def test_n1_some_error_code():
93+
ret = main((
94+
'n1', sys.executable, '-c',
95+
'import sys; raise SystemExit(sys.argv[1] == "error")',
96+
'--',
97+
'ok', 'error', 'ok',
98+
))
99+
assert ret == 1

tests/lang_base_test.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,15 @@ def test_basic_run_hook(tmp_path):
164164
assert ret == 0
165165
out = out.replace(b'\r\n', b'\n')
166166
assert out == b'hi hello file file file\n'
167+
168+
169+
def test_hook_cmd():
170+
assert lang_base.hook_cmd('echo hi', ()) == ('echo', 'hi')
171+
172+
173+
def test_hook_cmd_hazmat():
174+
ret = lang_base.hook_cmd('pre-commit hazmat cd a echo -- b', ())
175+
assert ret == (
176+
sys.executable, '-m', 'pre_commit.commands.hazmat',
177+
'cd', 'a', 'echo', '--', 'b',
178+
)

tests/main_test.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import pre_commit.constants as C
1010
from pre_commit import main
11+
from pre_commit.commands import hazmat
1112
from pre_commit.errors import FatalError
1213
from pre_commit.util import cmd_output
1314
from testing.auto_namedtuple import auto_namedtuple
@@ -158,6 +159,17 @@ def test_all_cmds(command, mock_commands, mock_store_dir):
158159
assert_only_one_mock_called(mock_commands)
159160

160161

162+
def test_hazmat(mock_store_dir):
163+
with mock.patch.object(hazmat, 'impl') as mck:
164+
main.main(('hazmat', 'cd', 'subdir', '--', 'cmd', '--', 'f1', 'f2'))
165+
assert mck.call_count == 1
166+
(arg,), dct = mck.call_args
167+
assert dct == {}
168+
assert arg.tool == 'cd'
169+
assert arg.subdir == 'subdir'
170+
assert arg.cmd == ['cmd', '--', 'f1', 'f2']
171+
172+
161173
def test_try_repo(mock_store_dir):
162174
with mock.patch.object(main, 'try_repo') as patch:
163175
main.main(('try-repo', '.'))

tests/repository_test.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,3 +506,14 @@ def test_args_with_spaces_and_quotes(tmp_path):
506506

507507
expected = b"['i have spaces', 'and\"\\'quotes', '$and !this']\n"
508508
assert ret == (0, expected)
509+
510+
511+
def test_hazmat(tmp_path):
512+
ret = run_language(
513+
tmp_path, unsupported,
514+
f'pre-commit hazmat ignore-exit-code {shlex.quote(sys.executable)} '
515+
f"-c 'import sys; raise SystemExit(sys.argv[1:])'",
516+
('f1', 'f2'),
517+
)
518+
expected = b"['f1', 'f2']\n"
519+
assert ret == (0, expected)

0 commit comments

Comments
 (0)