diff --git a/docs/changelog/2442.bugfix.rst b/docs/changelog/2442.bugfix.rst new file mode 100644 index 000000000..58e1f6434 --- /dev/null +++ b/docs/changelog/2442.bugfix.rst @@ -0,0 +1 @@ +In POSIX virtual environments, try alternate binary names if ``sys._base_executable`` does not exist - by :user:`vfazio`. diff --git a/src/virtualenv/discovery/py_info.py b/src/virtualenv/discovery/py_info.py index 7c680eeaa..a2eb633bf 100644 --- a/src/virtualenv/discovery/py_info.py +++ b/src/virtualenv/discovery/py_info.py @@ -138,7 +138,25 @@ def _fast_get_system_executable(self): base_executable = getattr(sys, "_base_executable", None) # some platforms may set this to help us if base_executable is not None: # use the saved system executable if present if sys.executable != base_executable: # we know we're in a virtual environment, cannot be us - return base_executable + if os.path.exists(base_executable): + return base_executable + # Python may return "python" because it was invoked from the POSIX virtual environment + # however some installs/distributions do not provide a version-less "python" binary in + # the system install location (see PEP 394) so try to fallback to a versioned binary. + # + # Gate this to Python 3.11 as `sys._base_executable` path resolution is now relative to + # the 'home' key from pyvenv.cfg which often points to the system install location. + major, minor = self.version_info.major, self.version_info.minor + if self.os == "posix" and (major, minor) >= (3, 11): + # search relative to the directory of sys._base_executable + base_dir = os.path.dirname(base_executable) + for base_executable in [ + os.path.join(base_dir, "{}".format(exe)) + for exe in ("python{}".format(major), "python{}.{}".format(major, minor)) + ]: + if os.path.exists(base_executable): + return base_executable + return None # File doesn't exist and no alternate was available return None # in this case we just can't tell easily without poking around FS and calling them, bail # if we're not in a virtual environment, this is already a system python, so return the original executable # note we must choose the original and not the pure executable as shim scripts might throw us off diff --git a/tests/unit/discovery/py_info/test_py_info.py b/tests/unit/discovery/py_info/test_py_info.py index f621224ee..0c4b26a02 100644 --- a/tests/unit/discovery/py_info/test_py_info.py +++ b/tests/unit/discovery/py_info/test_py_info.py @@ -378,6 +378,29 @@ def test_custom_venv_install_scheme_is_prefered(mocker): assert pyinfo.install_path("purelib").replace(os.sep, "/") == f"lib/python{pyver}/site-packages" +@pytest.mark.skipif(not (os.name == "posix" and sys.version_info[:2] >= (3, 11)), reason="POSIX 3.11+ specific") +def test_fallback_existent_system_executable(mocker): + current = PythonInfo() + # Posix may execute a "python" out of a venv but try to set the base_executable + # to "python" out of the system installation path. PEP 394 informs distributions + # that "python" is not required and the standard `make install` does not provide one + + # Falsify some data to look like we're in a venv + current.prefix = current.exec_prefix = "/tmp/tmp.izZNCyINRj/venv" + current.executable = current.original_executable = os.path.join(current.prefix, "bin/python") + + # Since we don't know if the distribution we're on provides python, use a binary that should not exist + mocker.patch.object(sys, "_base_executable", os.path.join(os.path.dirname(current.system_executable), "idontexist")) + mocker.patch.object(sys, "executable", current.executable) + + # ensure it falls back to an alternate binary name that exists + current._fast_get_system_executable() + assert os.path.basename(current.system_executable) in [ + f"python{v}" for v in (current.version_info.major, f"{current.version_info.major}.{current.version_info.minor}") + ] + assert os.path.exists(current.system_executable) + + @pytest.mark.skipif(sys.version_info[:2] != (3, 10), reason="3.10 specific") def test_uses_posix_prefix_on_debian_3_10_without_venv(mocker): # this is taken from ubuntu 22.04 /usr/lib/python3.10/sysconfig.py