Repository: pypa/virtualenv

Issue:
[20.x]: regression: no longer works with `-p python3.7m`
**Issue**

originally reported here: https://github.com/pre-commit/pre-commit/issues/810#issuecomment-602698550

this previously worked in the 16.x era

**Environment**

Provide at least:
- OS: ubuntu 18.04
- ``pip list`` of the host python where ``virtualenv`` is installed:

  ```console
   $ pip freeze --all
   appdirs==1.4.3
   distlib==0.3.0
   filelock==3.0.12
   importlib-metadata==1.5.0
   importlib-resources==1.4.0
   pip==20.0.2
   setuptools==46.0.0
   six==1.14.0
   virtualenv==20.0.13
   wheel==0.34.2
   zipp==3.1.0
  ```

**Output of the virtual environment creation**

Make sure to run the creation with `-vvv --with-traceback`:

```console
$ virtualenv vvv -vvv --with-traceback -p python3.7m
86 setup logging to NOTSET [DEBUG report:43]
129 find interpreter for spec PythonSpec(path=python3.7m) [INFO builtin:44]
130 discover PATH[0]=/home/asottile/bin [DEBUG builtin:84]
130 discover PATH[1]=/home/asottile/bin [DEBUG builtin:84]
130 discover PATH[2]=/usr/local/sbin [DEBUG builtin:84]
130 discover PATH[3]=/usr/local/bin [DEBUG builtin:84]
131 discover PATH[4]=/usr/sbin [DEBUG builtin:84]
131 discover PATH[5]=/usr/bin [DEBUG builtin:84]
131 Attempting to acquire lock 140599339566024 on /home/asottile/.local/share/virtualenv/py_info/20.0.13/7de8334bc336d5cad061541f9e57f300ac341259d2150b7467db7f17faa68e7d.lock [DEBUG filelock:270]
132 Lock 140599339566024 acquired on /home/asottile/.local/share/virtualenv/py_info/20.0.13/7de8334bc336d5cad061541f9e57f300ac341259d2150b7467db7f17faa68e7d.lock [INFO filelock:274]
132 get PythonInfo from /home/asottile/.local/share/virtualenv/py_info/20.0.13/7de8334bc336d5cad061541f9e57f300ac341259d2150b7467db7f17faa68e7d.json for /usr/bin/python3.7m [DEBUG cached_py_info:79]
133 Attempting to release lock 140599339566024 on /home/asottile/.local/share/virtualenv/py_info/20.0.13/7de8334bc336d5cad061541f9e57f300ac341259d2150b7467db7f17faa68e7d.lock [DEBUG filelock:315]
133 Lock 140599339566024 released on /home/asottile/.local/share/virtualenv/py_info/20.0.13/7de8334bc336d5cad061541f9e57f300ac341259d2150b7467db7f17faa68e7d.lock [INFO filelock:318]
133 proposed PathPythonInfo(spec=CPython3.7.7.final.0-64, exe=/usr/bin/python3.7m, platform=linux, version='3.7.7 (default, Mar 10 2020, 15:16:38) \n[GCC 7.5.0]', encoding_fs_io=utf-8-UTF-8) [INFO builtin:50]
134 Attempting to acquire lock 140599339565632 on /home/asottile/.local/share/virtualenv/py_info/20.0.13/767d51c1340bd893661ea55ea3124f6de3c7a262a8b4abca0554b478b1e2ff90.lock [DEBUG filelock:270]
134 Lock 140599339565632 acquired on /home/asottile/.local/share/virtualenv/py_info/20.0.13/767d51c1340bd893661ea55ea3124f6de3c7a262a8b4abca0554b478b1e2ff90.lock [INFO filelock:274]
135 get PythonInfo from /home/asottile/.local/share/virtualenv/py_info/20.0.13/767d51c1340bd893661ea55ea3124f6de3c7a262a8b4abca0554b478b1e2ff90.json for /usr/bin/python [DEBUG cached_py_info:79]
135 Attempting to release lock 140599339565632 on /home/asottile/.local/share/virtualenv/py_info/20.0.13/767d51c1340bd893661ea55ea3124f6de3c7a262a8b4abca0554b478b1e2ff90.lock [DEBUG filelock:315]
135 Lock 140599339565632 released on /home/asottile/.local/share/virtualenv/py_info/20.0.13/767d51c1340bd893661ea55ea3124f6de3c7a262a8b4abca0554b478b1e2ff90.lock [INFO filelock:318]
135 proposed PathPythonInfo(spec=CPython2.7.17.final.0-64, exe=/usr/bin/python, platform=linux2, version='2.7.17 (default, Nov  7 2019, 10:07:09) \n[GCC 7.4.0]', encoding_fs_io=UTF-8-None) [INFO builtin:50]
135 discover PATH[6]=/sbin [DEBUG builtin:84]
136 discover PATH[7]=/bin [DEBUG builtin:84]
136 discover PATH[8]=/usr/games [DEBUG builtin:84]
136 discover PATH[9]=/usr/local/games [DEBUG builtin:84]
Traceback (most recent call last):
  File "/home/asottile/bin/virtualenv", line 8, in <module>
    sys.exit(run_with_catch())
  File "/home/asottile/opt/venv/lib/python3.6/site-packages/virtualenv/__main__.py", line 51, in run_with_catch
    run(args, options)
  File "/home/asottile/opt/venv/lib/python3.6/site-packages/virtualenv/__main__.py", line 20, in run
    session = cli_run(args, options)
  File "/home/asottile/opt/venv/lib/python3.6/site-packages/virtualenv/run/__init__.py", line 27, in cli_run
    session = session_via_cli(args, options)
  File "/home/asottile/opt/venv/lib/python3.6/site-packages/virtualenv/run/__init__.py", line 35, in session_via_cli
    parser = build_parser(args, options)
  File "/home/asottile/opt/venv/lib/python3.6/site-packages/virtualenv/run/__init__.py", line 73, in build_parser
    raise RuntimeError("failed to find interpreter for {}".format(discover))
RuntimeError: failed to find interpreter for Builtin discover of python_spec='python3.7m'
```


Discussion:

Patch:
diff --git a/docs/changelog/1746.bugfix.rst b/docs/changelog/1746.bugfix.rst
new file mode 100644
index 00000000..f1e44bd2
--- /dev/null
+++ b/docs/changelog/1746.bugfix.rst
@@ -0,0 +1,1 @@
+Fix discovery of interpreter by name from ``PATH`` that does not match a spec format - by :user:`gaborbernat`.
diff --git a/src/virtualenv/discovery/py_info.py b/src/virtualenv/discovery/py_info.py
index 903ad7d1..d41c99cf 100644
--- a/src/virtualenv/discovery/py_info.py
+++ b/src/virtualenv/discovery/py_info.py
@@ -235,13 +235,19 @@ class PythonInfo(object):
 
     def satisfies(self, spec, impl_must_match):
         """check if a given specification can be satisfied by the this python interpreter instance"""
-        if spec.path and self.executable == os.path.abspath(spec.path):
-            return True  # if the path is a our own executable path we're done
-
-        if spec.path is not None:  # if path set, and is not our original executable name, this does not match
-            root, _ = os.path.splitext(os.path.basename(self.original_executable))
-            if root != spec.path:
-                return False
+        if spec.path:
+            if self.executable == os.path.abspath(spec.path):
+                return True  # if the path is a our own executable path we're done
+            if not spec.is_abs:
+                # if path set, and is not our original executable name, this does not match
+                basename = os.path.basename(self.original_executable)
+                spec_path = spec.path
+                if sys.platform == "win32":
+                    basename, suffix = os.path.splitext(basename)
+                    if spec_path.endswith(suffix):
+                        spec_path = spec_path[: -len(suffix)]
+                if basename != spec_path:
+                    return False
 
         if impl_must_match:
             if spec.implementation is not None and spec.implementation.lower() != self.implementation.lower():


Test patch:
diff --git a/tests/unit/discovery/py_info/test_py_info.py b/tests/unit/discovery/py_info/test_py_info.py
index 04001cbb..1fa62576 100644
--- a/tests/unit/discovery/py_info/test_py_info.py
+++ b/tests/unit/discovery/py_info/test_py_info.py
@@ -270,3 +270,23 @@ def test_py_info_ignores_distutils_config(monkeypatch, tmp_path):
     distutils = py_info.distutils_install
     for key, value in distutils.items():
         assert not value.startswith(str(tmp_path)), "{}={}".format(key, value)
+
+
+def test_discover_exe_on_path_non_spec_name_match(mocker):
+    suffixed_name = "python{}.{}m".format(CURRENT.version_info.major, CURRENT.version_info.minor)
+    if sys.platform == "win32":
+        suffixed_name += Path(CURRENT.original_executable).suffix
+    spec = PythonSpec.from_string_spec(suffixed_name)
+    mocker.patch.object(CURRENT, "original_executable", str(Path(CURRENT.executable).parent / suffixed_name))
+    assert CURRENT.satisfies(spec, impl_must_match=True) is True
+
+
+def test_discover_exe_on_path_non_spec_name_not_match(mocker):
+    suffixed_name = "python{}.{}m".format(CURRENT.version_info.major, CURRENT.version_info.minor)
+    if sys.platform == "win32":
+        suffixed_name += Path(CURRENT.original_executable).suffix
+    spec = PythonSpec.from_string_spec(suffixed_name)
+    mocker.patch.object(
+        CURRENT, "original_executable", str(Path(CURRENT.executable).parent / "e{}".format(suffixed_name))
+    )
+    assert CURRENT.satisfies(spec, impl_must_match=True) is False