Skip to content

Commit 45f1e5a

Browse files
committed
Add --require-env to check-executables-have-shebangs
1 parent 44d7f9b commit 45f1e5a

3 files changed

Lines changed: 183 additions & 14 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ Check for files with names that would conflict on a case-insensitive filesystem
4747

4848
#### `check-executables-have-shebangs`
4949
Checks that non-binary executables have a proper shebang.
50+
- `--require-env` - Require shebangs to invoke an interpreter through
51+
`/usr/bin/env` (e.g. `#!/usr/bin/env python`).
52+
- `--fix` - Rewrite existing shebangs to use `/usr/bin/env`
53+
(requires `--require-env`).
5054

5155
#### `check-illegal-windows-names`
5256
Check for files that cannot be created on Windows.

pre_commit_hooks/check_executables_have_shebangs.py

Lines changed: 104 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,22 @@
1414
EXECUTABLE_VALUES = frozenset(('1', '3', '5', '7'))
1515

1616

17-
def check_executables(paths: list[str]) -> int:
17+
def check_executables(
18+
paths: list[str], *, require_env: bool = False, fix: bool = False,
19+
) -> int:
1820
fs_tracks_executable_bit = cmd_output(
1921
'git', 'config', 'core.fileMode', retcode=None,
2022
).strip()
2123
if fs_tracks_executable_bit == 'false': # pragma: win32 cover
22-
return _check_git_filemode(paths)
24+
return _check_git_filemode(
25+
paths, require_env=require_env, fix=fix,
26+
)
2327
else: # pragma: win32 no cover
2428
retv = 0
2529
for path in paths:
26-
if not has_shebang(path):
27-
_message(path)
28-
retv = 1
30+
retv |= _check_executable(
31+
path, require_env=require_env, fix=fix,
32+
)
2933

3034
return retv
3135

@@ -43,42 +47,128 @@ def git_ls_files(paths: Sequence[str]) -> Generator[GitLsFile]:
4347
yield GitLsFile(mode, filename)
4448

4549

46-
def _check_git_filemode(paths: Sequence[str]) -> int:
50+
def _check_git_filemode(
51+
paths: Sequence[str], *, require_env: bool = False, fix: bool = False,
52+
) -> int:
4753
seen: set[str] = set()
4854
for ls_file in git_ls_files(paths):
4955
is_executable = any(b in EXECUTABLE_VALUES for b in ls_file.mode[-3:])
50-
if is_executable and not has_shebang(ls_file.filename):
51-
_message(ls_file.filename)
56+
if is_executable and _check_executable(
57+
ls_file.filename, require_env=require_env, fix=fix,
58+
):
5259
seen.add(ls_file.filename)
5360

5461
return int(bool(seen))
5562

5663

57-
def has_shebang(path: str) -> int:
64+
def has_shebang(path: str, *, require_env: bool = False) -> bool:
5865
with open(path, 'rb') as f:
5966
first_bytes = f.read(2)
67+
if first_bytes != b'#!':
68+
return False
69+
elif not require_env:
70+
return True
71+
else:
72+
cmd = f.readline().split()
73+
74+
return (
75+
len(cmd) >= 2 and
76+
cmd[0] == b'/usr/bin/env'
77+
)
6078

61-
return first_bytes == b'#!'
6279

80+
def _fix_shebang(path: str) -> bool:
81+
with open(path, 'rb+') as f:
82+
first_line = f.readline()
83+
if not first_line.startswith(b'#!'):
84+
return False
85+
86+
line = first_line.rstrip(b'\r\n')
87+
newline = first_line[len(line):]
88+
command = line[2:].strip()
89+
cmd = command.split(maxsplit=1)
90+
if not cmd:
91+
return False
92+
93+
executable = cmd[0].rsplit(b'/', 1)[-1]
94+
if not executable or (executable == b'env' and len(cmd) == 1):
95+
return False
96+
97+
if executable == b'env':
98+
new_first_line = b'#!/usr/bin/env ' + cmd[1] + newline
99+
elif len(cmd) == 2:
100+
new_first_line = (
101+
b'#!/usr/bin/env -S ' + executable + b' ' + cmd[1] + newline
102+
)
103+
else:
104+
new_first_line = b'#!/usr/bin/env ' + executable + newline
105+
106+
rest = f.read()
107+
f.seek(0)
108+
f.write(new_first_line)
109+
f.write(rest)
110+
f.truncate()
111+
112+
return True
113+
114+
115+
def _check_executable(
116+
path: str, *, require_env: bool, fix: bool,
117+
) -> int:
118+
if has_shebang(path, require_env=require_env):
119+
return 0
120+
elif fix and _fix_shebang(path):
121+
print(f'Fixing {path}')
122+
else:
123+
_message(path, require_env=require_env)
124+
125+
return 1
126+
127+
128+
def _message(path: str, *, require_env: bool = False) -> None:
129+
if require_env:
130+
problem = 'does not have a /usr/bin/env shebang'
131+
suggestion = (
132+
'use a /usr/bin/env shebang (e.g. `#!/usr/bin/env python`)'
133+
)
134+
else:
135+
problem = 'has no (or invalid) shebang'
136+
suggestion = 'double-check its shebang'
63137

64-
def _message(path: str) -> None:
65138
print(
66-
f'{path}: marked executable but has no (or invalid) shebang!\n'
139+
f'{path}: marked executable but {problem}!\n'
67140
f" If it isn't supposed to be executable, try: "
68141
f'`chmod -x {shlex.quote(path)}`\n'
69142
f' If on Windows, you may also need to: '
70143
f'`git add --chmod=-x {shlex.quote(path)}`\n'
71-
f' If it is supposed to be executable, double-check its shebang.',
144+
f' If it is supposed to be executable, {suggestion}.',
72145
file=sys.stderr,
73146
)
74147

75148

76149
def main(argv: Sequence[str] | None = None) -> int:
77150
parser = argparse.ArgumentParser(description=__doc__)
151+
parser.add_argument(
152+
'--require-env', action='store_true',
153+
help='Require shebangs to invoke an interpreter through /usr/bin/env',
154+
)
155+
parser.add_argument(
156+
'--fix', action='store_true',
157+
help=(
158+
'Rewrite existing shebangs to use /usr/bin/env '
159+
'(requires --require-env)'
160+
),
161+
)
78162
parser.add_argument('filenames', nargs='*')
79163
args = parser.parse_args(argv)
164+
if args.fix and not args.require_env:
165+
parser.error('--fix requires --require-env')
80166

81-
return check_executables(args.filenames)
167+
return check_executables(
168+
args.filenames,
169+
require_env=args.require_env,
170+
fix=args.fix,
171+
)
82172

83173

84174
if __name__ == '__main__':

tests/check_executables_have_shebangs_test.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,64 @@ def test_has_shebang(content, tmpdir):
3030
assert main((str(path),)) == 0
3131

3232

33+
@skip_win32 # pragma: win32 no cover
34+
@pytest.mark.parametrize(
35+
('content', 'expected'), (
36+
(b'#!/usr/bin/env python\n', 0),
37+
(b'#!/usr/bin/env -S python -O\n', 0),
38+
(b'#!/bin/env bash\n', 1),
39+
(b'#!/bin/bash\n', 1),
40+
(b'#!/usr/bin/env\n', 1),
41+
),
42+
)
43+
def test_require_env_shebang(content, expected, tmpdir):
44+
path = tmpdir.join('path')
45+
path.write(content, 'wb')
46+
assert main(('--require-env', str(path))) == expected
47+
48+
49+
@skip_win32 # pragma: win32 no cover
50+
@pytest.mark.parametrize(
51+
('content', 'expected'), (
52+
(b'#!/bin/bash\necho hi\n', b'#!/usr/bin/env bash\necho hi\n'),
53+
(
54+
b'#!/opt/bin/python -O\nprint("hi")\n',
55+
b'#!/usr/bin/env -S python -O\nprint("hi")\n',
56+
),
57+
(b'#!/bin/env bash\necho hi\n', b'#!/usr/bin/env bash\necho hi\n'),
58+
),
59+
)
60+
def test_require_env_fix(content, expected, tmpdir, capsys):
61+
path = tmpdir.join('path')
62+
path.write(content, 'wb')
63+
64+
assert main(('--require-env', '--fix', str(path))) == 1
65+
stdout, stderr = capsys.readouterr()
66+
assert stdout == f'Fixing {path}\n'
67+
assert stderr == ''
68+
assert path.read('rb') == expected
69+
70+
assert main(('--require-env', '--fix', str(path))) == 0
71+
72+
73+
@skip_win32 # pragma: win32 no cover
74+
@pytest.mark.parametrize(
75+
'content', (b'echo hi\n', b'#!\n', b'#!/\n', b'#!/bin/env\n'),
76+
)
77+
def test_require_env_fix_cannot_infer_interpreter(content, tmpdir):
78+
path = tmpdir.join('path')
79+
path.write(content, 'wb')
80+
81+
assert main(('--require-env', '--fix', str(path))) == 1
82+
assert path.read('rb') == content
83+
84+
85+
def test_fix_requires_require_env():
86+
with pytest.raises(SystemExit) as excinfo:
87+
main(('--fix',))
88+
assert excinfo.value.code == 2
89+
90+
3391
@skip_win32 # pragma: win32 no cover
3492
@pytest.mark.parametrize(
3593
'content', (
@@ -104,6 +162,23 @@ def test_check_git_filemode_failing(tmpdir):
104162
assert check_executables_have_shebangs._check_git_filemode(files) == 1
105163

106164

165+
def test_check_git_filemode_require_env(tmpdir):
166+
with tmpdir.as_cwd():
167+
cmd_output('git', 'init', '.')
168+
169+
f = tmpdir.join('f')
170+
f.write('#!/bin/bash')
171+
f_path = str(f)
172+
cmd_output('git', 'add', f_path)
173+
cmd_output('git', 'update-index', '--chmod=+x', f_path)
174+
175+
files = (f_path,)
176+
assert check_executables_have_shebangs._check_git_filemode(files) == 0
177+
assert check_executables_have_shebangs._check_git_filemode(
178+
files, require_env=True,
179+
) == 1
180+
181+
107182
@pytest.mark.parametrize(
108183
('content', 'mode', 'expected'),
109184
(

0 commit comments

Comments
 (0)