Repository: pre-commit/pre-commit

Issue:
Detect the python version when creating `default` py_envs
Given two separate virtualenvs, one running with python3 and the other running with python2, and each with the appropriate pre-commit installed (`pip install pre-commit` in the first, `pip3 install pre-commit` in the second), the check-ast plugin fails to parse Python 2 (or 3) syntax correctly, depending on which virtualenv pre-commit was installed in first.

Suggestions: 
- the ~/.pre-commit cache should instead be a per virtualenv cache, rather than per user.
- instead of maintaining the cache, consider just using pip dependencies instead.

Example failure in Python 2 venv if Python 3 venv installs pre-commit first:

```
p2/main.py: failed parsing with python3.4:

    Traceback (most recent call last):
      File "/home/ubuntu/.pre-commit/repo_3mr61_f/py_env-default/lib/python3.4/site-packages/pre_commit_hooks/check_ast.py", line 23, in check_ast
        ast.parse(open(filename, 'rb').read(), filename=filename)
      File "/usr/lib/python3.4/ast.py", line 35, in parse
        return compile(source, filename, mode, PyCF_ONLY_AST)
      File "p2/main.py", line 178
        except socket.error, e:
                           ^
    SyntaxError: invalid syntax
```

Example failure in Python 3 venv if Python 2 venv installs pre-commit first:

```
p3/__main__.py: failed parsing with python:

    Traceback (most recent call last):
      File "/home/ubuntu/.pre-commit/repoCzM4lg/py_env-default/local/lib/python2.7/site-packages/pre_commit_hooks/check_ast.py", line 23, in check_ast
        ast.parse(open(filename, 'rb').read(), filename=filename)
      File "/usr/lib/python2.7/ast.py", line 37, in parse
        return compile(source, filename, mode, PyCF_ONLY_AST)
      File "p3/__main__.py", line 30
        "c=%s" % args.c, file=sys.stderr)
                                ^
    SyntaxError: invalid syntax
```

`rm -fr ~/.pre-commit` was run between each test.


Discussion:
asottile: test failures are expected, I'll fix those up

Patch:
diff --git a/pre_commit/languages/all.py b/pre_commit/languages/all.py
index f441ddd..5546025 100644
--- a/pre_commit/languages/all.py
+++ b/pre_commit/languages/all.py
@@ -10,16 +10,18 @@ from pre_commit.languages import script
 from pre_commit.languages import swift
 from pre_commit.languages import system
 
-# A language implements the following constant and two functions in its module:
+# A language implements the following constant and functions in its module:
 #
 # # Use None for no environment
 # ENVIRONMENT_DIR = 'foo_env'
 #
-# def install_environment(
-#         repo_cmd_runner,
-#         version='default',
-#         additional_dependencies=(),
-# ):
+# def get_default_version():
+#     """Return a value to replace the 'default' value for language_version.
+#
+#     return 'default' if there is no better option.
+#    """
+#
+# def install_environment(repo_cmd_runner, version, additional_dependencies):
 #     """Installs a repository in the given repository.  Note that the current
 #     working directory will already be inside the repository.
 #
diff --git a/pre_commit/languages/docker.py b/pre_commit/languages/docker.py
index 7d3f8d0..59dc1b4 100644
--- a/pre_commit/languages/docker.py
+++ b/pre_commit/languages/docker.py
@@ -14,6 +14,7 @@ from pre_commit.xargs import xargs
 
 ENVIRONMENT_DIR = 'docker'
 PRE_COMMIT_LABEL = 'PRE_COMMIT'
+get_default_version = helpers.basic_get_default_version
 
 
 def md5(s):  # pragma: windows no cover
@@ -55,9 +56,7 @@ def build_docker_image(repo_cmd_runner, **kwargs):  # pragma: windows no cover
 
 
 def install_environment(
-        repo_cmd_runner,
-        version='default',
-        additional_dependencies=(),
+        repo_cmd_runner, version, additional_dependencies,
 ):  # pragma: windows no cover
     assert repo_cmd_runner.exists('Dockerfile'), (
         'No Dockerfile was found in the hook repository'
diff --git a/pre_commit/languages/golang.py b/pre_commit/languages/golang.py
index c0bfbcb..ee04ca7 100644
--- a/pre_commit/languages/golang.py
+++ b/pre_commit/languages/golang.py
@@ -14,6 +14,7 @@ from pre_commit.xargs import xargs
 
 
 ENVIRONMENT_DIR = 'golangenv'
+get_default_version = helpers.basic_get_default_version
 
 
 def get_env_patch(venv):
@@ -44,11 +45,7 @@ def guess_go_dir(remote_url):
         return 'unknown_src_dir'
 
 
-def install_environment(
-        repo_cmd_runner,
-        version='default',
-        additional_dependencies=(),
-):
+def install_environment(repo_cmd_runner, version, additional_dependencies):
     helpers.assert_version_default('golang', version)
     directory = repo_cmd_runner.path(
         helpers.environment_dir(ENVIRONMENT_DIR, 'default'),
diff --git a/pre_commit/languages/helpers.py b/pre_commit/languages/helpers.py
index a6c93de..6af77e3 100644
--- a/pre_commit/languages/helpers.py
+++ b/pre_commit/languages/helpers.py
@@ -33,3 +33,7 @@ def assert_no_additional_deps(lang, additional_deps):
             'For now, pre-commit does not support '
             'additional_dependencies for {}'.format(lang),
         )
+
+
+def basic_get_default_version():
+    return 'default'
diff --git a/pre_commit/languages/node.py b/pre_commit/languages/node.py
index ef557a1..b5f7c56 100644
--- a/pre_commit/languages/node.py
+++ b/pre_commit/languages/node.py
@@ -12,6 +12,7 @@ from pre_commit.xargs import xargs
 
 
 ENVIRONMENT_DIR = 'node_env'
+get_default_version = helpers.basic_get_default_version
 
 
 def get_env_patch(venv):  # pragma: windows no cover
@@ -34,9 +35,7 @@ def in_env(repo_cmd_runner, language_version):  # pragma: windows no cover
 
 
 def install_environment(
-        repo_cmd_runner,
-        version='default',
-        additional_dependencies=(),
+        repo_cmd_runner, version, additional_dependencies,
 ):  # pragma: windows no cover
     additional_dependencies = tuple(additional_dependencies)
     assert repo_cmd_runner.exists('package.json')
diff --git a/pre_commit/languages/pcre.py b/pre_commit/languages/pcre.py
index 314ea09..faba539 100644
--- a/pre_commit/languages/pcre.py
+++ b/pre_commit/languages/pcre.py
@@ -2,18 +2,16 @@ from __future__ import unicode_literals
 
 import sys
 
+from pre_commit.languages import helpers
 from pre_commit.xargs import xargs
 
 
 ENVIRONMENT_DIR = None
 GREP = 'ggrep' if sys.platform == 'darwin' else 'grep'
+get_default_version = helpers.basic_get_default_version
 
 
-def install_environment(
-        repo_cmd_runner,
-        version='default',
-        additional_dependencies=(),
-):
+def install_environment(repo_cmd_runner, version, additional_dependencies):
     """Installation for pcre type is a noop."""
     raise AssertionError('Cannot install pcre repo.')
 
diff --git a/pre_commit/languages/python.py b/pre_commit/languages/python.py
index 634abe5..715d585 100644
--- a/pre_commit/languages/python.py
+++ b/pre_commit/languages/python.py
@@ -1,7 +1,6 @@
 from __future__ import unicode_literals
 
 import contextlib
-import distutils.spawn
 import os
 import sys
 
@@ -9,11 +8,13 @@ from pre_commit.envcontext import envcontext
 from pre_commit.envcontext import UNSET
 from pre_commit.envcontext import Var
 from pre_commit.languages import helpers
+from pre_commit.parse_shebang import find_executable
 from pre_commit.util import clean_path_on_failure
 from pre_commit.xargs import xargs
 
 
 ENVIRONMENT_DIR = 'py_env'
+get_default_version = helpers.basic_get_default_version
 
 
 def bin_dir(venv):
@@ -39,10 +40,53 @@ def in_env(repo_cmd_runner, language_version):
         yield
 
 
+def _get_default_version():  # pragma: no cover (platform dependent)
+    def _norm(path):
+        _, exe = os.path.split(path.lower())
+        exe, _, _ = exe.partition('.exe')
+        if find_executable(exe) and exe not in {'python', 'pythonw'}:
+            return exe
+
+    # First attempt from `sys.executable` (or the realpath)
+    # On linux, I see these common sys.executables:
+    #
+    # system `python`: /usr/bin/python -> python2.7
+    # system `python2`: /usr/bin/python2 -> python2.7
+    # virtualenv v: v/bin/python (will not return from this loop)
+    # virtualenv v -ppython2: v/bin/python -> python2
+    # virtualenv v -ppython2.7: v/bin/python -> python2.7
+    # virtualenv v -ppypy: v/bin/python -> v/bin/pypy
+    for path in {sys.executable, os.path.realpath(sys.executable)}:
+        exe = _norm(path)
+        if exe:
+            return exe
+
+    # Next try the `pythonX.X` executable
+    exe = 'python{}.{}'.format(*sys.version_info)
+    if find_executable(exe):
+        return exe
+
+    # Give a best-effort try for windows
+    if os.path.exists(r'C:\{}\python.exe'.format(exe.replace('.', ''))):
+        return exe
+
+    # We tried!
+    return 'default'
+
+
+def get_default_version():
+    # TODO: when dropping python2, use `functools.lru_cache(maxsize=1)`
+    try:
+        return get_default_version.cached_version
+    except AttributeError:
+        get_default_version.cached_version = _get_default_version()
+        return get_default_version()
+
+
 def norm_version(version):
     if os.name == 'nt':  # pragma: no cover (windows)
         # Try looking up by name
-        if distutils.spawn.find_executable(version):
+        if find_executable(version) and find_executable(version) != version:
             return version
 
         # If it is in the form pythonx.x search in the default
@@ -54,11 +98,7 @@ def norm_version(version):
     return os.path.expanduser(version)
 
 
-def install_environment(
-        repo_cmd_runner,
-        version='default',
-        additional_dependencies=(),
-):
+def install_environment(repo_cmd_runner, version, additional_dependencies):
     additional_dependencies = tuple(additional_dependencies)
     directory = helpers.environment_dir(ENVIRONMENT_DIR, version)
 
diff --git a/pre_commit/languages/ruby.py b/pre_commit/languages/ruby.py
index d3896d9..26e303c 100644
--- a/pre_commit/languages/ruby.py
+++ b/pre_commit/languages/ruby.py
@@ -16,6 +16,7 @@ from pre_commit.xargs import xargs
 
 
 ENVIRONMENT_DIR = 'rbenv'
+get_default_version = helpers.basic_get_default_version
 
 
 def get_env_patch(venv, language_version):  # pragma: windows no cover
@@ -97,9 +98,7 @@ def _install_ruby(runner, version):  # pragma: windows no cover
 
 
 def install_environment(
-        repo_cmd_runner,
-        version='default',
-        additional_dependencies=(),
+        repo_cmd_runner, version, additional_dependencies,
 ):  # pragma: windows no cover
     additional_dependencies = tuple(additional_dependencies)
     directory = helpers.environment_dir(ENVIRONMENT_DIR, version)
diff --git a/pre_commit/languages/script.py b/pre_commit/languages/script.py
index 762ae76..c4b6593 100644
--- a/pre_commit/languages/script.py
+++ b/pre_commit/languages/script.py
@@ -5,13 +5,10 @@ from pre_commit.xargs import xargs
 
 
 ENVIRONMENT_DIR = None
+get_default_version = helpers.basic_get_default_version
 
 
-def install_environment(
-        repo_cmd_runner,
-        version='default',
-        additional_dependencies=(),
-):
+def install_environment(repo_cmd_runner, version, additional_dependencies):
     """Installation for script type is a noop."""
     raise AssertionError('Cannot install script repo.')
 
diff --git a/pre_commit/languages/swift.py b/pre_commit/languages/swift.py
index 4d171c5..a27dfac 100644
--- a/pre_commit/languages/swift.py
+++ b/pre_commit/languages/swift.py
@@ -10,6 +10,7 @@ from pre_commit.util import clean_path_on_failure
 from pre_commit.xargs import xargs
 
 ENVIRONMENT_DIR = 'swift_env'
+get_default_version = helpers.basic_get_default_version
 BUILD_DIR = '.build'
 BUILD_CONFIG = 'release'
 
@@ -29,9 +30,7 @@ def in_env(repo_cmd_runner):  # pragma: windows no cover
 
 
 def install_environment(
-        repo_cmd_runner,
-        version='default',
-        additional_dependencies=(),
+        repo_cmd_runner, version, additional_dependencies,
 ):  # pragma: windows no cover
     helpers.assert_version_default('swift', version)
     helpers.assert_no_additional_deps('swift', additional_dependencies)
diff --git a/pre_commit/languages/system.py b/pre_commit/languages/system.py
index c9e1c5d..3148079 100644
--- a/pre_commit/languages/system.py
+++ b/pre_commit/languages/system.py
@@ -5,13 +5,10 @@ from pre_commit.xargs import xargs
 
 
 ENVIRONMENT_DIR = None
+get_default_version = helpers.basic_get_default_version
 
 
-def install_environment(
-        repo_cmd_runner,
-        version='default',
-        additional_dependencies=(),
-):
+def install_environment(repo_cmd_runner, version, additional_dependencies):
     """Installation for system type is a noop."""
     raise AssertionError('Cannot install system repo.')
 
diff --git a/pre_commit/manifest.py b/pre_commit/manifest.py
index 888ad6d..081f3c6 100644
--- a/pre_commit/manifest.py
+++ b/pre_commit/manifest.py
@@ -7,6 +7,7 @@ from cached_property import cached_property
 
 import pre_commit.constants as C
 from pre_commit.clientlib import load_manifest
+from pre_commit.languages.all import languages
 
 
 logger = logging.getLogger('pre_commit')
@@ -38,4 +39,10 @@ class Manifest(object):
 
     @cached_property
     def hooks(self):
-        return {hook['id']: hook for hook in self.manifest_contents}
+        ret = {}
+        for hook in self.manifest_contents:
+            if hook['language_version'] == 'default':
+                language = languages[hook['language']]
+                hook['language_version'] = language.get_default_version()
+            ret[hook['id']] = hook
+        return ret


Test patch:
diff --git a/tests/languages/all_test.py b/tests/languages/all_test.py
index 73b89cb..dd1ed27 100644
--- a/tests/languages/all_test.py
+++ b/tests/languages/all_test.py
@@ -12,9 +12,7 @@ from pre_commit.languages.all import languages
 def test_install_environment_argspec(language):
     expected_argspec = inspect.ArgSpec(
         args=['repo_cmd_runner', 'version', 'additional_dependencies'],
-        varargs=None,
-        keywords=None,
-        defaults=('default', ()),
+        varargs=None, keywords=None, defaults=None,
     )
     argspec = inspect.getargspec(languages[language].install_environment)
     assert argspec == expected_argspec
@@ -33,3 +31,12 @@ def test_run_hook_argpsec(language):
     )
     argspec = inspect.getargspec(languages[language].run_hook)
     assert argspec == expected_argspec
+
+
+@pytest.mark.parametrize('language', all_languages)
+def test_get_default_version_argspec(language):
+    expected_argspec = inspect.ArgSpec(
+        args=[], varargs=None, keywords=None, defaults=None,
+    )
+    argspec = inspect.getargspec(languages[language].get_default_version)
+    assert argspec == expected_argspec
diff --git a/tests/manifest_test.py b/tests/manifest_test.py
index 7db886c..ada004f 100644
--- a/tests/manifest_test.py
+++ b/tests/manifest_test.py
@@ -11,8 +11,7 @@ from testing.util import get_head_sha
 @pytest.yield_fixture
 def manifest(store, tempdir_factory):
     path = make_repo(tempdir_factory, 'script_hooks_repo')
-    head_sha = get_head_sha(path)
-    repo_path = store.clone(path, head_sha)
+    repo_path = store.clone(path, get_head_sha(path))
     yield Manifest(repo_path, path)
 
 
@@ -76,3 +75,13 @@ def test_legacy_manifest_warn(store, tempdir_factory, log_warning_mock):
         'If `pre-commit autoupdate` does not silence this warning consider '
         'making an issue / pull request.'.format(path)
     )
+
+
+def test_default_python_language_version(store, tempdir_factory):
+    path = make_repo(tempdir_factory, 'python_hooks_repo')
+    repo_path = store.clone(path, get_head_sha(path))
+    manifest = Manifest(repo_path, path)
+
+    # This assertion is difficult as it is version dependent, just assert
+    # that it is *something*
+    assert manifest.hooks['foo']['language_version'] != 'default'
diff --git a/tests/repository_test.py b/tests/repository_test.py
index f91642e..7131d75 100644
--- a/tests/repository_test.py
+++ b/tests/repository_test.py
@@ -442,7 +442,7 @@ def test_venvs(tempdir_factory, store):
     config = make_config_from_repo(path)
     repo = Repository.create(config, store)
     venv, = repo._venvs
-    assert venv == (mock.ANY, 'python', 'default', [])
+    assert venv == (mock.ANY, 'python', python.get_default_version(), [])
 
 
 @pytest.mark.integration
@@ -452,7 +452,7 @@ def test_additional_dependencies(tempdir_factory, store):
     config['hooks'][0]['additional_dependencies'] = ['pep8']
     repo = Repository.create(config, store)
     venv, = repo._venvs
-    assert venv == (mock.ANY, 'python', 'default', ['pep8'])
+    assert venv == (mock.ANY, 'python', python.get_default_version(), ['pep8'])
 
 
 @pytest.mark.integration
@@ -591,7 +591,8 @@ def test_control_c_control_c_on_install(tempdir_factory, store):
                 repo.run_hook(hook, [])
 
     # Should have made an environment, however this environment is broken!
-    assert os.path.exists(repo._cmd_runner.path('py_env-default'))
+    envdir = 'py_env-{}'.format(python.get_default_version())
+    assert repo._cmd_runner.exists(envdir)
 
     # However, it should be perfectly runnable (reinstall after botched
     # install)