Repository: pre-commit/pre-commit

Issue:
"script" language without a shebang causes unexpected OSError
Repro steps:
Create a script hook.
```
-   repo: local
    hooks:
    -   id: foo
        name: foo
        language: script
        entry: foo.py
        types: [python]
```
`foo.py` can be empty.

If foo.py is not executable, `pre-commit run foo --all-files` produces a reasonable error message:
```
foo......................................................................Failed
- hook id: foo
- exit code: 1

Executable `/[redacted]/foo.py` is not executable
```

However, if it is executable but doesn't not contain a shebang, you get
```
$ pre-commit run foo --all-files
foo......................................................................An unexpected error has occurred: OSError: [Errno 8] Exec format error: '/[redacted]/foo.py'
Check the log at /[redacted]/.cache/pre-commit/pre-commit.log
```

Log contents:
### version information

```
pre-commit version: 2.1.1
sys.version:
    3.7.6 (default, Dec 30 2019, 19:38:28) 
    [Clang 11.0.0 (clang-1100.0.33.16)]
sys.executable: /[redacted]/venv/bin/python3
os.name: posix
sys.platform: darwin
```

### error information

```
An unexpected error has occurred: OSError: [Errno 8] Exec format error: '/[redacted]/foo.py'
```

```
Traceback (most recent call last):
  File "/[redacted]/venv/lib/python3.7/site-packages/pre_commit/error_handler.py", line 54, in error_handler
    yield
  File "/[redacted]/venv/lib/python3.7/site-packages/pre_commit/main.py", line 371, in main
    return run(args.config, store, args)
  File "/[redacted]/venv/lib/python3.7/site-packages/pre_commit/commands/run.py", line 339, in run
    return _run_hooks(config, hooks, args, environ)
  File "/[redacted]/venv/lib/python3.7/site-packages/pre_commit/commands/run.py", line 249, in _run_hooks
    verbose=args.verbose, use_color=args.color,
  File "/[redacted]/venv/lib/python3.7/site-packages/pre_commit/commands/run.py", line 165, in _run_single_hook
    retcode, out = language.run_hook(hook, filenames, use_color)
  File "/[redacted]/venv/lib/python3.7/site-packages/pre_commit/languages/script.py", line 19, in run_hook
    return helpers.run_xargs(hook, cmd, file_args, color=color)
  File "/[redacted]/venv/lib/python3.7/site-packages/pre_commit/languages/helpers.py", line 109, in run_xargs
    return xargs(cmd, file_args, **kwargs)
  File "/[redacted]/venv/lib/python3.7/site-packages/pre_commit/xargs.py", line 153, in xargs
    for proc_retcode, proc_out, _ in results:
  File "/usr/local/Cellar/python/3.7.6_1/Frameworks/Python.framework/Versions/3.7/lib/python3.7/concurrent/futures/_base.py", line 598, in result_iterator
    yield fs.pop().result()
  File "/usr/local/Cellar/python/3.7.6_1/Frameworks/Python.framework/Versions/3.7/lib/python3.7/concurrent/futures/_base.py", line 428, in result
    return self.__get_result()
  File "/usr/local/Cellar/python/3.7.6_1/Frameworks/Python.framework/Versions/3.7/lib/python3.7/concurrent/futures/_base.py", line 384, in __get_result
    raise self._exception
  File "/usr/local/Cellar/python/3.7.6_1/Frameworks/Python.framework/Versions/3.7/lib/python3.7/concurrent/futures/thread.py", line 57, in run
    result = self.fn(*self.args, **self.kwargs)
  File "/[redacted]/venv/lib/python3.7/site-packages/pre_commit/xargs.py", line 146, in run_cmd_partition
    *run_cmd, retcode=None, stderr=subprocess.STDOUT, **kwargs,
  File "/[redacted]/venv/lib/python3.7/site-packages/pre_commit/util.py", line 208, in cmd_output_p
    proc = subprocess.Popen(cmd, **kwargs)
  File "/usr/local/Cellar/python/3.7.6_1/Frameworks/Python.framework/Versions/3.7/lib/python3.7/subprocess.py", line 800, in __init__
    restore_signals, start_new_session)
  File "/usr/local/Cellar/python/3.7.6_1/Frameworks/Python.framework/Versions/3.7/lib/python3.7/subprocess.py", line 1551, in _execute_child
    raise child_exception_type(errno_num, err_msg, err_filename)
OSError: [Errno 8] Exec format error: '/[redacted]/foo.py'

```


Discussion:

Patch:
diff --git a/pre_commit/error_handler.py b/pre_commit/error_handler.py
index b095ba2..b2321ae 100644
--- a/pre_commit/error_handler.py
+++ b/pre_commit/error_handler.py
@@ -8,23 +8,15 @@ from typing import Generator
 import pre_commit.constants as C
 from pre_commit import output
 from pre_commit.store import Store
+from pre_commit.util import force_bytes
 
 
 class FatalError(RuntimeError):
     pass
 
 
-def _exception_to_bytes(exc: BaseException) -> bytes:
-    with contextlib.suppress(TypeError):
-        return bytes(exc)  # type: ignore
-    with contextlib.suppress(Exception):
-        return str(exc).encode()
-    return f'<unprintable {type(exc).__name__} object>'.encode()
-
-
 def _log_and_exit(msg: str, exc: BaseException, formatted: str) -> None:
-    error_msg = f'{msg}: {type(exc).__name__}: '.encode()
-    error_msg += _exception_to_bytes(exc)
+    error_msg = f'{msg}: {type(exc).__name__}: '.encode() + force_bytes(exc)
     output.write_line_b(error_msg)
     log_path = os.path.join(Store().directory, 'pre-commit.log')
     output.write_line(f'Check the log at {log_path}')
diff --git a/pre_commit/util.py b/pre_commit/util.py
index 7da41c4..2db579a 100644
--- a/pre_commit/util.py
+++ b/pre_commit/util.py
@@ -43,6 +43,14 @@ def yaml_dump(o: Any) -> str:
     )
 
 
+def force_bytes(exc: Any) -> bytes:
+    with contextlib.suppress(TypeError):
+        return bytes(exc)
+    with contextlib.suppress(Exception):
+        return str(exc).encode()
+    return f'<unprintable {type(exc).__name__} object>'.encode()
+
+
 @contextlib.contextmanager
 def clean_path_on_failure(path: str) -> Generator[None, None, None]:
     """Cleans up the directory on an exceptional failure."""
@@ -120,6 +128,10 @@ def _setdefault_kwargs(kwargs: Dict[str, Any]) -> None:
         kwargs.setdefault(arg, subprocess.PIPE)
 
 
+def _oserror_to_output(e: OSError) -> Tuple[int, bytes, None]:
+    return 1, force_bytes(e).rstrip(b'\n') + b'\n', None
+
+
 def cmd_output_b(
         *cmd: str,
         retcode: Optional[int] = 0,
@@ -132,9 +144,13 @@ def cmd_output_b(
     except parse_shebang.ExecutableNotFoundError as e:
         returncode, stdout_b, stderr_b = e.to_output()
     else:
-        proc = subprocess.Popen(cmd, **kwargs)
-        stdout_b, stderr_b = proc.communicate()
-        returncode = proc.returncode
+        try:
+            proc = subprocess.Popen(cmd, **kwargs)
+        except OSError as e:
+            returncode, stdout_b, stderr_b = _oserror_to_output(e)
+        else:
+            stdout_b, stderr_b = proc.communicate()
+            returncode = proc.returncode
 
     if retcode is not None and retcode != returncode:
         raise CalledProcessError(returncode, cmd, retcode, stdout_b, stderr_b)
@@ -205,7 +221,11 @@ if os.name != 'nt':  # pragma: win32 no cover
         with open(os.devnull) as devnull, Pty() as pty:
             assert pty.r is not None
             kwargs.update({'stdin': devnull, 'stdout': pty.w, 'stderr': pty.w})
-            proc = subprocess.Popen(cmd, **kwargs)
+            try:
+                proc = subprocess.Popen(cmd, **kwargs)
+            except OSError as e:
+                return _oserror_to_output(e)
+
             pty.close_w()
 
             buf = b''


Test patch:
diff --git a/tests/util_test.py b/tests/util_test.py
index 9f75f6a..01afbd4 100644
--- a/tests/util_test.py
+++ b/tests/util_test.py
@@ -9,6 +9,7 @@ from pre_commit.util import clean_path_on_failure
 from pre_commit.util import cmd_output
 from pre_commit.util import cmd_output_b
 from pre_commit.util import cmd_output_p
+from pre_commit.util import make_executable
 from pre_commit.util import parse_version
 from pre_commit.util import rmtree
 from pre_commit.util import tmpdir
@@ -92,6 +93,18 @@ def test_cmd_output_exe_not_found_bytes(fn):
     assert out == b'Executable `dne` not found'
 
 
+@pytest.mark.parametrize('fn', (cmd_output_b, cmd_output_p))
+def test_cmd_output_no_shebang(tmpdir, fn):
+    f = tmpdir.join('f').ensure()
+    make_executable(f)
+
+    # previously this raised `OSError` -- the output is platform specific
+    ret, out, _ = fn(str(f), retcode=None, stderr=subprocess.STDOUT)
+    assert ret == 1
+    assert isinstance(out, bytes)
+    assert out.endswith(b'\n')
+
+
 def test_parse_version():
     assert parse_version('0.0') == parse_version('0.0')
     assert parse_version('0.1') > parse_version('0.0')