Repository: pre-commit/pre-commit (python)

Issue:
Cannot specify custom Python shebang for hook executable
I am working on a shared Python environment with `python3.6` as executable and I want the shebang to point to the correct executable `#!/usr/bin/env python3.6`. However, even though I specify 
```
default_language_version:
    python: python3.6
```
or  `language_version: python3.6` in `hooks`, it always picks `#!/usr/bin/env python` as the shebang even though there is no `python` executable. Is there a way to specify a custom shebang?

Pull request description:
Improve git hook shebang creation
Resolves #1312

Patch:
diff --git a/pre_commit/commands/install_uninstall.py b/pre_commit/commands/install_uninstall.py
index b2ccc5c..7011873 100644
--- a/pre_commit/commands/install_uninstall.py
+++ b/pre_commit/commands/install_uninstall.py
@@ -30,6 +30,10 @@ PRIOR_HASHES = (
 CURRENT_HASH = '138fd403232d2ddd5efb44317e38bf03'
 TEMPLATE_START = '# start templated\n'
 TEMPLATE_END = '# end templated\n'
+# Homebrew/homebrew-core#35825: be more timid about appropriate `PATH`
+# #1312 os.defpath is too restrictive on BSD
+POSIX_SEARCH_PATH = ('/usr/local/bin', '/usr/bin', '/bin')
+SYS_EXE = os.path.basename(os.path.realpath(sys.executable))
 
 
 def _hook_paths(
@@ -51,20 +55,21 @@ def is_our_script(filename: str) -> bool:
 
 def shebang() -> str:
     if sys.platform == 'win32':
-        py = 'python'
+        py = SYS_EXE
     else:
-        # Homebrew/homebrew-core#35825: be more timid about appropriate `PATH`
-        path_choices = [p for p in os.defpath.split(os.pathsep) if p]
         exe_choices = [
             f'python{sys.version_info[0]}.{sys.version_info[1]}',
             f'python{sys.version_info[0]}',
         ]
-        for path, exe in itertools.product(path_choices, exe_choices):
+        # avoid searching for bare `python` as it's likely to be python 2
+        if SYS_EXE != 'python':
+            exe_choices.append(SYS_EXE)
+        for path, exe in itertools.product(POSIX_SEARCH_PATH, exe_choices):
             if os.access(os.path.join(path, exe), os.X_OK):
                 py = exe
                 break
         else:
-            py = 'python'
+            py = SYS_EXE
     return f'#!/usr/bin/env {py}'
 
 
diff --git a/pre_commit/languages/conda.py b/pre_commit/languages/conda.py
index 071757a..2c187e0 100644
--- a/pre_commit/languages/conda.py
+++ b/pre_commit/languages/conda.py
@@ -72,7 +72,7 @@ def install_environment(
 
 
 def run_hook(
-        hook: Hook,
+        hook: 'Hook',
         file_args: Sequence[str],
         color: bool,
 ) -> Tuple[int, bytes]:
diff --git a/pre_commit/languages/docker.py b/pre_commit/languages/docker.py
index 921401f..364a699 100644
--- a/pre_commit/languages/docker.py
+++ b/pre_commit/languages/docker.py
@@ -97,7 +97,7 @@ def docker_cmd() -> Tuple[str, ...]:  # pragma: windows no cover
 
 
 def run_hook(
-        hook: Hook,
+        hook: 'Hook',
         file_args: Sequence[str],
         color: bool,
 ) -> Tuple[int, bytes]:  # pragma: windows no cover
diff --git a/pre_commit/languages/docker_image.py b/pre_commit/languages/docker_image.py
index 980c6ef..58da34c 100644
--- a/pre_commit/languages/docker_image.py
+++ b/pre_commit/languages/docker_image.py
@@ -13,7 +13,7 @@ install_environment = helpers.no_install
 
 
 def run_hook(
-        hook: Hook,
+        hook: 'Hook',
         file_args: Sequence[str],
         color: bool,
 ) -> Tuple[int, bytes]:  # pragma: windows no cover
diff --git a/pre_commit/languages/fail.py b/pre_commit/languages/fail.py
index d2b02d2..8cdc76c 100644
--- a/pre_commit/languages/fail.py
+++ b/pre_commit/languages/fail.py
@@ -11,7 +11,7 @@ install_environment = helpers.no_install
 
 
 def run_hook(
-        hook: Hook,
+        hook: 'Hook',
         file_args: Sequence[str],
         color: bool,
 ) -> Tuple[int, bytes]:
diff --git a/pre_commit/languages/golang.py b/pre_commit/languages/golang.py
index 91ade1e..cdcff0d 100644
--- a/pre_commit/languages/golang.py
+++ b/pre_commit/languages/golang.py
@@ -89,7 +89,7 @@ def install_environment(
 
 
 def run_hook(
-        hook: Hook,
+        hook: 'Hook',
         file_args: Sequence[str],
         color: bool,
 ) -> Tuple[int, bytes]:
diff --git a/pre_commit/languages/helpers.py b/pre_commit/languages/helpers.py
index b5c95e5..ba96568 100644
--- a/pre_commit/languages/helpers.py
+++ b/pre_commit/languages/helpers.py
@@ -72,7 +72,7 @@ def no_install(
     raise AssertionError('This type is not installable')
 
 
-def target_concurrency(hook: Hook) -> int:
+def target_concurrency(hook: 'Hook') -> int:
     if hook.require_serial or 'PRE_COMMIT_NO_CONCURRENCY' in os.environ:
         return 1
     else:
@@ -97,7 +97,7 @@ def _shuffled(seq: Sequence[str]) -> List[str]:
 
 
 def run_xargs(
-        hook: Hook,
+        hook: 'Hook',
         cmd: Tuple[str, ...],
         file_args: Sequence[str],
         **kwargs: Any,
diff --git a/pre_commit/languages/node.py b/pre_commit/languages/node.py
index 787bcd7..481b065 100644
--- a/pre_commit/languages/node.py
+++ b/pre_commit/languages/node.py
@@ -85,7 +85,7 @@ def install_environment(
 
 
 def run_hook(
-        hook: Hook,
+        hook: 'Hook',
         file_args: Sequence[str],
         color: bool,
 ) -> Tuple[int, bytes]:
diff --git a/pre_commit/languages/perl.py b/pre_commit/languages/perl.py
index bbf5504..f61815a 100644
--- a/pre_commit/languages/perl.py
+++ b/pre_commit/languages/perl.py
@@ -59,7 +59,7 @@ def install_environment(
 
 
 def run_hook(
-        hook: Hook,
+        hook: 'Hook',
         file_args: Sequence[str],
         color: bool,
 ) -> Tuple[int, bytes]:
diff --git a/pre_commit/languages/pygrep.py b/pre_commit/languages/pygrep.py
index 40adba0..68eb6e9 100644
--- a/pre_commit/languages/pygrep.py
+++ b/pre_commit/languages/pygrep.py
@@ -46,7 +46,7 @@ def _process_filename_at_once(pattern: Pattern[bytes], filename: str) -> int:
 
 
 def run_hook(
-        hook: Hook,
+        hook: 'Hook',
         file_args: Sequence[str],
         color: bool,
 ) -> Tuple[int, bytes]:
diff --git a/pre_commit/languages/python.py b/pre_commit/languages/python.py
index caa7794..2a5cfe7 100644
--- a/pre_commit/languages/python.py
+++ b/pre_commit/languages/python.py
@@ -145,7 +145,7 @@ def py_interface(
 ) -> Tuple[
     Callable[[Prefix, str], ContextManager[None]],
     Callable[[Prefix, str], bool],
-    Callable[[Hook, Sequence[str], bool], Tuple[int, bytes]],
+    Callable[['Hook', Sequence[str], bool], Tuple[int, bytes]],
     Callable[[Prefix, str, Sequence[str]], None],
 ]:
     @contextlib.contextmanager
@@ -168,7 +168,7 @@ def py_interface(
         return retcode == 0
 
     def run_hook(
-            hook: Hook,
+            hook: 'Hook',
             file_args: Sequence[str],
             color: bool,
     ) -> Tuple[int, bytes]:
diff --git a/pre_commit/languages/ruby.py b/pre_commit/languages/ruby.py
index 26bd5be..828216f 100644
--- a/pre_commit/languages/ruby.py
+++ b/pre_commit/languages/ruby.py
@@ -118,7 +118,7 @@ def install_environment(
 
 
 def run_hook(
-        hook: Hook,
+        hook: 'Hook',
         file_args: Sequence[str],
         color: bool,
 ) -> Tuple[int, bytes]:  # pragma: windows no cover
diff --git a/pre_commit/languages/rust.py b/pre_commit/languages/rust.py
index 7ea3f54..feb3684 100644
--- a/pre_commit/languages/rust.py
+++ b/pre_commit/languages/rust.py
@@ -98,7 +98,7 @@ def install_environment(
 
 
 def run_hook(
-        hook: Hook,
+        hook: 'Hook',
         file_args: Sequence[str],
         color: bool,
 ) -> Tuple[int, bytes]:
diff --git a/pre_commit/languages/script.py b/pre_commit/languages/script.py
index a5e1365..1f6f354 100644
--- a/pre_commit/languages/script.py
+++ b/pre_commit/languages/script.py
@@ -11,7 +11,7 @@ install_environment = helpers.no_install
 
 
 def run_hook(
-        hook: Hook,
+        hook: 'Hook',
         file_args: Sequence[str],
         color: bool,
 ) -> Tuple[int, bytes]:
diff --git a/pre_commit/languages/swift.py b/pre_commit/languages/swift.py
index a022bce..9f36b15 100644
--- a/pre_commit/languages/swift.py
+++ b/pre_commit/languages/swift.py
@@ -56,7 +56,7 @@ def install_environment(
 
 
 def run_hook(
-        hook: Hook,
+        hook: 'Hook',
         file_args: Sequence[str],
         color: bool,
 ) -> Tuple[int, bytes]:  # pragma: windows no cover
diff --git a/pre_commit/languages/system.py b/pre_commit/languages/system.py
index 139f45d..424e14f 100644
--- a/pre_commit/languages/system.py
+++ b/pre_commit/languages/system.py
@@ -12,7 +12,7 @@ install_environment = helpers.no_install
 
 
 def run_hook(
-        hook: Hook,
+        hook: 'Hook',
         file_args: Sequence[str],
         color: bool,
 ) -> Tuple[int, bytes]:


Test patch:
diff --git a/tests/commands/install_uninstall_test.py b/tests/commands/install_uninstall_test.py
index 6d48614..e8e7261 100644
--- a/tests/commands/install_uninstall_test.py
+++ b/tests/commands/install_uninstall_test.py
@@ -4,6 +4,7 @@ import sys
 from unittest import mock
 
 import pre_commit.constants as C
+from pre_commit.commands import install_uninstall
 from pre_commit.commands.install_uninstall import CURRENT_HASH
 from pre_commit.commands.install_uninstall import install
 from pre_commit.commands.install_uninstall import install_hooks
@@ -39,25 +40,36 @@ def test_is_previous_pre_commit(tmpdir):
     assert is_our_script(f.strpath)
 
 
+def patch_platform(platform):
+    return mock.patch.object(sys, 'platform', platform)
+
+
+def patch_lookup_path(path):
+    return mock.patch.object(install_uninstall, 'POSIX_SEARCH_PATH', path)
+
+
+def patch_sys_exe(exe):
+    return mock.patch.object(install_uninstall, 'SYS_EXE', exe)
+
+
 def test_shebang_windows():
-    with mock.patch.object(sys, 'platform', 'win32'):
-        assert shebang() == '#!/usr/bin/env python'
+    with patch_platform('win32'), patch_sys_exe('python.exe'):
+        assert shebang() == '#!/usr/bin/env python.exe'
 
 
 def test_shebang_posix_not_on_path():
-    with mock.patch.object(sys, 'platform', 'posix'):
-        with mock.patch.object(os, 'defpath', ''):
-            assert shebang() == '#!/usr/bin/env python'
+    with patch_platform('posix'), patch_lookup_path(()):
+        with patch_sys_exe('python3.6'):
+            assert shebang() == '#!/usr/bin/env python3.6'
 
 
 def test_shebang_posix_on_path(tmpdir):
     exe = tmpdir.join(f'python{sys.version_info[0]}').ensure()
     make_executable(exe)
 
-    with mock.patch.object(sys, 'platform', 'posix'):
-        with mock.patch.object(os, 'defpath', tmpdir.strpath):
-            expected = f'#!/usr/bin/env python{sys.version_info[0]}'
-            assert shebang() == expected
+    with patch_platform('posix'), patch_lookup_path((tmpdir.strpath,)):
+        with patch_sys_exe('python'):
+            assert shebang() == f'#!/usr/bin/env python{sys.version_info[0]}'
 
 
 def test_install_pre_commit(in_git_dir, store):
@@ -250,9 +262,18 @@ def _path_without_us():
 def test_environment_not_sourced(tempdir_factory, store):
     path = make_consuming_repo(tempdir_factory, 'script_hooks_repo')
     with cwd(path):
-        # Patch the executable to simulate rming virtualenv
-        with mock.patch.object(sys, 'executable', '/does-not-exist'):
-            assert not install(C.CONFIG_FILE, store, hook_types=['pre-commit'])
+        assert not install(C.CONFIG_FILE, store, hook_types=['pre-commit'])
+        # simulate deleting the virtualenv by rewriting the exe
+        hook = os.path.join(path, '.git/hooks/pre-commit')
+        with open(hook) as f:
+            src = f.read()
+        src = re.sub(
+            '\nINSTALL_PYTHON =.*\n',
+            '\nINSTALL_PYTHON = "/dne"\n',
+            src,
+        )
+        with open(hook, 'w') as f:
+            f.write(src)
 
         # Use a specific homedir to ignore --user installs
         homedir = tempdir_factory.get()