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 random
import re import re
import shlex import shlex
import sys
from collections.abc import Generator from collections.abc import Generator
from collections.abc import Sequence from collections.abc import Sequence
from typing import Any from typing import Any
@ -171,7 +172,10 @@ def run_xargs(
def hook_cmd(entry: str, args: Sequence[str]) -> tuple[str, ...]: 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( 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 clientlib
from pre_commit import git from pre_commit import git
from pre_commit.color import add_color_option 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.autoupdate import autoupdate
from pre_commit.commands.clean import clean from pre_commit.commands.clean import clean
from pre_commit.commands.gc import gc from pre_commit.commands.gc import gc
@ -41,7 +42,7 @@ os.environ.pop('__PYVENV_LAUNCHER__', None)
os.environ.pop('PYTHONEXECUTABLE', None) os.environ.pop('PYTHONEXECUTABLE', None)
COMMANDS_NO_GIT = { COMMANDS_NO_GIT = {
'clean', 'gc', 'init-templatedir', 'sample-config', 'clean', 'gc', 'hazmat', 'init-templatedir', 'sample-config',
'validate-config', 'validate-manifest', 'validate-config', 'validate-manifest',
} }
@ -245,6 +246,11 @@ def main(argv: Sequence[str] | None = None) -> int:
_add_cmd('gc', help='Clean unused cached repos.') _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_parser = _add_cmd(
'init-templatedir', 'init-templatedir',
help=( help=(
@ -389,6 +395,8 @@ def main(argv: Sequence[str] | None = None) -> int:
return clean(store) return clean(store)
elif args.command == 'gc': elif args.command == 'gc':
return gc(store) return gc(store)
elif args.command == 'hazmat':
return hazmat.impl(args)
elif args.command == 'hook-impl': elif args.command == 'hook-impl':
return hook_impl( return hook_impl(
store, 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 assert ret == 0
out = out.replace(b'\r\n', b'\n') out = out.replace(b'\r\n', b'\n')
assert out == b'hi hello file file file\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 import pre_commit.constants as C
from pre_commit import main from pre_commit import main
from pre_commit.commands import hazmat
from pre_commit.errors import FatalError from pre_commit.errors import FatalError
from pre_commit.util import cmd_output from pre_commit.util import cmd_output
from testing.auto_namedtuple import auto_namedtuple 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) 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): def test_try_repo(mock_store_dir):
with mock.patch.object(main, 'try_repo') as patch: with mock.patch.object(main, 'try_repo') as patch:
main.main(('try-repo', '.')) 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" expected = b"['i have spaces', 'and\"\\'quotes', '$and !this']\n"
assert ret == (0, expected) 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)