Merge pull request #3585 from pre-commit/hazmat

add pre-commit hazmat
This commit is contained in:
anthony sottile 2025-11-22 14:03:09 -05:00 committed by GitHub
commit 3358a3b540
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 243 additions and 2 deletions

View file

@ -0,0 +1,95 @@
from __future__ import annotations
import argparse
import subprocess
from collections.abc import Sequence
from pre_commit.parse_shebang import normalize_cmd
def add_parsers(parser: argparse.ArgumentParser) -> None:
subparsers = parser.add_subparsers(dest='tool')
cd_parser = subparsers.add_parser(
'cd', help='cd to a subdir and run the command',
)
cd_parser.add_argument('subdir')
cd_parser.add_argument('cmd', nargs=argparse.REMAINDER)
ignore_exit_code_parser = subparsers.add_parser(
'ignore-exit-code', help='run the command but ignore the exit code',
)
ignore_exit_code_parser.add_argument('cmd', nargs=argparse.REMAINDER)
n1_parser = subparsers.add_parser(
'n1', help='run the command once per filename',
)
n1_parser.add_argument('cmd', nargs=argparse.REMAINDER)
def _cmd_filenames(cmd: tuple[str, ...]) -> tuple[
tuple[str, ...],
tuple[str, ...],
]:
for idx, val in enumerate(reversed(cmd)):
if val == '--':
split = len(cmd) - idx
break
else:
raise SystemExit('hazmat entry must end with `--`')
return cmd[:split - 1], cmd[split:]
def cd(subdir: str, cmd: tuple[str, ...]) -> int:
cmd, filenames = _cmd_filenames(cmd)
prefix = f'{subdir}/'
new_filenames = []
for filename in filenames:
if not filename.startswith(prefix):
raise SystemExit(f'unexpected file without {prefix=}: {filename}')
else:
new_filenames.append(filename.removeprefix(prefix))
cmd = normalize_cmd(cmd)
return subprocess.call((*cmd, *new_filenames), cwd=subdir)
def ignore_exit_code(cmd: tuple[str, ...]) -> int:
cmd = normalize_cmd(cmd)
subprocess.call(cmd)
return 0
def n1(cmd: tuple[str, ...]) -> int:
cmd, filenames = _cmd_filenames(cmd)
cmd = normalize_cmd(cmd)
ret = 0
for filename in filenames:
ret |= subprocess.call((*cmd, filename))
return ret
def impl(args: argparse.Namespace) -> int:
args.cmd = tuple(args.cmd)
if args.tool == 'cd':
return cd(args.subdir, args.cmd)
elif args.tool == 'ignore-exit-code':
return ignore_exit_code(args.cmd)
elif args.tool == 'n1':
return n1(args.cmd)
else:
raise NotImplementedError(f'unexpected tool: {args.tool}')
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser()
add_parsers(parser)
args = parser.parse_args(argv)
return impl(args)
if __name__ == '__main__':
raise SystemExit(main())

View file

@ -5,6 +5,7 @@ import os
import random
import re
import shlex
import sys
from collections.abc import Generator
from collections.abc import Sequence
from typing import Any
@ -171,7 +172,10 @@ def run_xargs(
def hook_cmd(entry: str, args: Sequence[str]) -> tuple[str, ...]:
return (*shlex.split(entry), *args)
cmd = shlex.split(entry)
if cmd[:2] == ['pre-commit', 'hazmat']:
cmd = [sys.executable, '-m', 'pre_commit.commands.hazmat', *cmd[2:]]
return (*cmd, *args)
def basic_run_hook(

View file

@ -10,6 +10,7 @@ import pre_commit.constants as C
from pre_commit import clientlib
from pre_commit import git
from pre_commit.color import add_color_option
from pre_commit.commands import hazmat
from pre_commit.commands.autoupdate import autoupdate
from pre_commit.commands.clean import clean
from pre_commit.commands.gc import gc
@ -41,7 +42,7 @@ os.environ.pop('__PYVENV_LAUNCHER__', None)
os.environ.pop('PYTHONEXECUTABLE', None)
COMMANDS_NO_GIT = {
'clean', 'gc', 'init-templatedir', 'sample-config',
'clean', 'gc', 'hazmat', 'init-templatedir', 'sample-config',
'validate-config', 'validate-manifest',
}
@ -245,6 +246,11 @@ def main(argv: Sequence[str] | None = None) -> int:
_add_cmd('gc', help='Clean unused cached repos.')
hazmat_parser = _add_cmd(
'hazmat', help='Composable tools for rare use in hook `entry`.',
)
hazmat.add_parsers(hazmat_parser)
init_templatedir_parser = _add_cmd(
'init-templatedir',
help=(
@ -389,6 +395,8 @@ def main(argv: Sequence[str] | None = None) -> int:
return clean(store)
elif args.command == 'gc':
return gc(store)
elif args.command == 'hazmat':
return hazmat.impl(args)
elif args.command == 'hook-impl':
return hook_impl(
store,

View file

@ -0,0 +1,99 @@
from __future__ import annotations
import sys
import pytest
from pre_commit.commands.hazmat import _cmd_filenames
from pre_commit.commands.hazmat import main
from testing.util import cwd
def test_cmd_filenames_no_dash_dash():
with pytest.raises(SystemExit) as excinfo:
_cmd_filenames(('no', 'dashdash', 'here'))
msg, = excinfo.value.args
assert msg == 'hazmat entry must end with `--`'
def test_cmd_filenames_no_filenames():
cmd, filenames = _cmd_filenames(('hello', 'world', '--'))
assert cmd == ('hello', 'world')
assert filenames == ()
def test_cmd_filenames_some_filenames():
cmd, filenames = _cmd_filenames(('hello', 'world', '--', 'f1', 'f2'))
assert cmd == ('hello', 'world')
assert filenames == ('f1', 'f2')
def test_cmd_filenames_multiple_dashdash():
cmd, filenames = _cmd_filenames(('hello', '--', 'arg', '--', 'f1', 'f2'))
assert cmd == ('hello', '--', 'arg')
assert filenames == ('f1', 'f2')
def test_cd_unexpected_filename():
with pytest.raises(SystemExit) as excinfo:
main(('cd', 'subdir', 'cmd', '--', 'subdir/1', 'not-subdir/2'))
msg, = excinfo.value.args
assert msg == "unexpected file without prefix='subdir/': not-subdir/2"
def _norm(out):
return out.replace('\r\n', '\n')
def test_cd(tmp_path, capfd):
subdir = tmp_path.joinpath('subdir')
subdir.mkdir()
subdir.joinpath('a').write_text('a')
subdir.joinpath('b').write_text('b')
with cwd(tmp_path):
ret = main((
'cd', 'subdir',
sys.executable, '-c',
'import os; print(os.getcwd());'
'import sys; [print(open(f).read()) for f in sys.argv[1:]]',
'--',
'subdir/a', 'subdir/b',
))
assert ret == 0
out, err = capfd.readouterr()
assert _norm(out) == f'{subdir}\na\nb\n'
assert err == ''
def test_ignore_exit_code(capfd):
ret = main((
'ignore-exit-code', sys.executable, '-c', 'raise SystemExit("bye")',
))
assert ret == 0
out, err = capfd.readouterr()
assert out == ''
assert _norm(err) == 'bye\n'
def test_n1(capfd):
ret = main((
'n1', sys.executable, '-c', 'import sys; print(sys.argv[1:])',
'--',
'foo', 'bar', 'baz',
))
assert ret == 0
out, err = capfd.readouterr()
assert _norm(out) == "['foo']\n['bar']\n['baz']\n"
assert err == ''
def test_n1_some_error_code():
ret = main((
'n1', sys.executable, '-c',
'import sys; raise SystemExit(sys.argv[1] == "error")',
'--',
'ok', 'error', 'ok',
))
assert ret == 1

View file

@ -164,3 +164,15 @@ def test_basic_run_hook(tmp_path):
assert ret == 0
out = out.replace(b'\r\n', b'\n')
assert out == b'hi hello file file file\n'
def test_hook_cmd():
assert lang_base.hook_cmd('echo hi', ()) == ('echo', 'hi')
def test_hook_cmd_hazmat():
ret = lang_base.hook_cmd('pre-commit hazmat cd a echo -- b', ())
assert ret == (
sys.executable, '-m', 'pre_commit.commands.hazmat',
'cd', 'a', 'echo', '--', 'b',
)

View file

@ -9,6 +9,7 @@ import pytest
import pre_commit.constants as C
from pre_commit import main
from pre_commit.commands import hazmat
from pre_commit.errors import FatalError
from pre_commit.util import cmd_output
from testing.auto_namedtuple import auto_namedtuple
@ -157,6 +158,17 @@ def test_all_cmds(command, mock_commands, mock_store_dir):
assert_only_one_mock_called(mock_commands)
def test_hazmat(mock_store_dir):
with mock.patch.object(hazmat, 'impl') as mck:
main.main(('hazmat', 'cd', 'subdir', '--', 'cmd', '--', 'f1', 'f2'))
assert mck.call_count == 1
(arg,), dct = mck.call_args
assert dct == {}
assert arg.tool == 'cd'
assert arg.subdir == 'subdir'
assert arg.cmd == ['cmd', '--', 'f1', 'f2']
def test_try_repo(mock_store_dir):
with mock.patch.object(main, 'try_repo') as patch:
main.main(('try-repo', '.'))

View file

@ -506,3 +506,14 @@ def test_args_with_spaces_and_quotes(tmp_path):
expected = b"['i have spaces', 'and\"\\'quotes', '$and !this']\n"
assert ret == (0, expected)
def test_hazmat(tmp_path):
ret = run_language(
tmp_path, unsupported,
f'pre-commit hazmat ignore-exit-code {shlex.quote(sys.executable)} '
f"-c 'import sys; raise SystemExit(sys.argv[1:])'",
('f1', 'f2'),
)
expected = b"['f1', 'f2']\n"
assert ret == (0, expected)