Repository: pre-commit/pre-commit
Pull request #3430 (2025-03-18 18:56:38)

adjust python default_language_version to prefer versioned exe


Patch:
diff --git a/pre_commit/languages/python.py b/pre_commit/languages/python.py
index 0c4bb62..88ececc 100644
--- a/pre_commit/languages/python.py
+++ b/pre_commit/languages/python.py
@@ -75,6 +75,13 @@ def _find_by_py_launcher(
     return None
 
 
+def _impl_exe_name() -> str:
+    if sys.implementation.name == 'cpython':  # pragma: cpython cover
+        return 'python'
+    else:  # pragma: cpython no cover
+        return sys.implementation.name  # pypy mostly
+
+
 def _find_by_sys_executable() -> str | None:
     def _norm(path: str) -> str | None:
         _, exe = os.path.split(path.lower())
@@ -100,18 +107,25 @@ def _find_by_sys_executable() -> str | None:
 
 @functools.lru_cache(maxsize=1)
 def get_default_version() -> str:  # pragma: no cover (platform dependent)
-    # First attempt from `sys.executable` (or the realpath)
-    exe = _find_by_sys_executable()
-    if exe:
-        return exe
-
-    # Next try the `pythonX.X` executable
-    exe = f'python{sys.version_info[0]}.{sys.version_info[1]}'
-    if find_executable(exe):
-        return exe
-
-    if _find_by_py_launcher(exe):
-        return exe
+    v_major = f'{sys.version_info[0]}'
+    v_minor = f'{sys.version_info[0]}.{sys.version_info[1]}'
+
+    # attempt the likely implementation exe
+    for potential in (v_minor, v_major):
+        exe = f'{_impl_exe_name()}{potential}'
+        if find_executable(exe):
+            return exe
+
+    # next try `sys.executable` (or the realpath)
+    maybe_exe = _find_by_sys_executable()
+    if maybe_exe:
+        return maybe_exe
+
+    # maybe on windows we can find it via py launcher?
+    if sys.platform == 'win32':  # pragma: win32 cover
+        exe = f'python{v_minor}'
+        if _find_by_py_launcher(exe):
+            return exe
 
     # We tried!
     return C.DEFAULT


Test patch:
diff --git a/tests/languages/python_test.py b/tests/languages/python_test.py
index ab26e14..565525a 100644
--- a/tests/languages/python_test.py
+++ b/tests/languages/python_test.py
@@ -12,6 +12,7 @@ from pre_commit.languages import python
 from pre_commit.prefix import Prefix
 from pre_commit.util import make_executable
 from pre_commit.util import win_exe
+from testing.auto_namedtuple import auto_namedtuple
 from testing.language_helpers import run_language
 
 
@@ -34,6 +35,72 @@ def test_read_pyvenv_cfg_non_utf8(tmpdir):
     assert python._read_pyvenv_cfg(pyvenv_cfg) == expected
 
 
+def _get_default_version(
+        *,
+        impl: str,
+        exe: str,
+        found: set[str],
+        version: tuple[int, int],
+) -> str:
+    sys_exe = f'/fake/path/{exe}'
+    sys_impl = auto_namedtuple(name=impl)
+    sys_ver = auto_namedtuple(major=version[0], minor=version[1])
+
+    def find_exe(s):
+        if s in found:
+            return f'/fake/path/found/{exe}'
+        else:
+            return None
+
+    with (
+            mock.patch.object(sys, 'implementation', sys_impl),
+            mock.patch.object(sys, 'executable', sys_exe),
+            mock.patch.object(sys, 'version_info', sys_ver),
+            mock.patch.object(python, 'find_executable', find_exe),
+    ):
+        return python.get_default_version.__wrapped__()
+
+
+def test_default_version_sys_executable_found():
+    ret = _get_default_version(
+        impl='cpython',
+        exe='python3.12',
+        found={'python3.12'},
+        version=(3, 12),
+    )
+    assert ret == 'python3.12'
+
+
+def test_default_version_picks_specific_when_found():
+    ret = _get_default_version(
+        impl='cpython',
+        exe='python3',
+        found={'python3', 'python3.12'},
+        version=(3, 12),
+    )
+    assert ret == 'python3.12'
+
+
+def test_default_version_picks_pypy_versioned_exe():
+    ret = _get_default_version(
+        impl='pypy',
+        exe='python',
+        found={'pypy3.12', 'python3'},
+        version=(3, 12),
+    )
+    assert ret == 'pypy3.12'
+
+
+def test_default_version_picks_pypy_unversioned_exe():
+    ret = _get_default_version(
+        impl='pypy',
+        exe='python',
+        found={'pypy3', 'python3'},
+        version=(3, 12),
+    )
+    assert ret == 'pypy3'
+
+
 def test_norm_version_expanduser():
     home = os.path.expanduser('~')
     if sys.platform == 'win32':  # pragma: win32 cover