Repository: pypa/virtualenv (python)

Issue:
Interpreter lookup fails when first `-p` value is absolute and missing
We use virtualenv and tox to run tests against a commercial application that embeds its own Python interpreter. The location of that interpreter varies by OS, so we pass all the possible locations as `-p` values to virtualenv by providing multiple values of `basepython` on `tox.ini`.

**Issue**

When providing the `-p` / `--python` multiple time, virtualenv creation fails if a missing, absolute path occurs before an existing path.

**Output of the virtual environment creation**

```console
# This works fine:
$ virtualenv -p this/doesnt/exist -p python3.9 dest
created virtual environment CPython3.9.5.final.0-64 in 363ms
[snip]
# But this fails
$ virtualenv -p /this/doesnt/exist -p python3.9 dest
FileNotFoundError: [Errno 2] No such file or directory: '/this/doesnt/exist'
```

I'd expect the absolute path to be handled just like the relative path, e.g. the failure should be ignored and we should move on to the next `-p` value.

Pull request:
Fix: Ignore missing absolute paths for python discovery
When providing multiple `-p` options to `virtualenv`, the creation would fail if a missing absolute path was encountered before a valid one. This change ensures that missing absolute paths are ignored, just like relative paths, allowing the discovery process to continue and find a valid Python interpreter.

Fixes #2870

### Thanks for contributing, make sure you address all the checklists (for details on how see [development documentation](https://virtualenv.pypa.io/en/latest/development.html#development))

- [x] ran the linter to address style issues (`tox -e fix`)
- [x] wrote descriptive pull request text
- [x] ensured there are test(s) validating the fix
- [x] added news fragment in `docs/changelog` folder
- [ ] updated/extended the documentation


Patch:
diff --git a/docs/changelog/2870.bugfix.rst b/docs/changelog/2870.bugfix.rst
new file mode 100644
index 00000000..79b82f80
--- /dev/null
+++ b/docs/changelog/2870.bugfix.rst
@@ -0,0 +1,1 @@
+Ignore missing absolute paths for python discovery - by :user:`esafak`
diff --git a/src/virtualenv/activation/fish/activate.fish b/src/virtualenv/activation/fish/activate.fish
index f3cd1f2a..518bc4cf 100644
--- a/src/virtualenv/activation/fish/activate.fish
+++ b/src/virtualenv/activation/fish/activate.fish
@@ -18,7 +18,7 @@ function deactivate -d 'Exit virtualenv mode and return to the normal environmen
     # reset old environment variables
     if test -n "$_OLD_VIRTUAL_PATH"
         # https://github.com/fish-shell/fish-shell/issues/436 altered PATH handling
-        if test (echo $FISH_VERSION | head -c 1) -lt 3
+        if test (string sub -s 1 -l 1 $FISH_VERSION) -lt 3
             set -gx PATH (_fishify_path "$_OLD_VIRTUAL_PATH")
         else
             set -gx PATH $_OLD_VIRTUAL_PATH
@@ -61,7 +61,7 @@ deactivate nondestructive
 set -gx VIRTUAL_ENV __VIRTUAL_ENV__
 
 # https://github.com/fish-shell/fish-shell/issues/436 altered PATH handling
-if test (echo $FISH_VERSION | head -c 1) -lt 3
+if test (string sub -s 1 -l 1 $FISH_VERSION) -lt 3
     set -gx _OLD_VIRTUAL_PATH (_bashify_path $PATH)
 else
     set -gx _OLD_VIRTUAL_PATH $PATH
diff --git a/src/virtualenv/discovery/builtin.py b/src/virtualenv/discovery/builtin.py
index 5012667a..917c22f1 100644
--- a/src/virtualenv/discovery/builtin.py
+++ b/src/virtualenv/discovery/builtin.py
@@ -116,8 +116,7 @@ def propose_interpreters(  # noqa: C901, PLR0912, PLR0915
         try:
             os.lstat(spec.path)  # Windows Store Python does not work with os.path.exists, but does for os.lstat
         except OSError:
-            if spec.is_abs:
-                raise
+            pass
         else:
             exe_raw = os.path.abspath(spec.path)
             exe_id = fs_path_id(exe_raw)


Test patch:
diff --git a/tests/unit/activation/test_fish.py b/tests/unit/activation/test_fish.py
index 6a92a279..0f3bee25 100644
--- a/tests/unit/activation/test_fish.py
+++ b/tests/unit/activation/test_fish.py
@@ -1,5 +1,8 @@
 from __future__ import annotations
 
+import os
+import sys
+
 import pytest
 
 from virtualenv.activation import FishActivator
@@ -20,4 +23,54 @@ def test_fish(activation_tester_class, activation_tester, monkeypatch, tmp_path)
         def print_prompt(self):
             return "fish_prompt"
 
+        def _get_test_lines(self, activate_script):
+            return [
+                self.print_python_exe(),
+                self.print_os_env_var("VIRTUAL_ENV"),
+                self.print_os_env_var("VIRTUAL_ENV_PROMPT"),
+                self.print_os_env_var("PATH"),
+                self.activate_call(activate_script),
+                self.print_python_exe(),
+                self.print_os_env_var("VIRTUAL_ENV"),
+                self.print_os_env_var("VIRTUAL_ENV_PROMPT"),
+                self.print_os_env_var("PATH"),
+                self.print_prompt(),
+                # \\ loads documentation from the virtualenv site packages
+                self.pydoc_call,
+                self.deactivate,
+                self.print_python_exe(),
+                self.print_os_env_var("VIRTUAL_ENV"),
+                self.print_os_env_var("VIRTUAL_ENV_PROMPT"),
+                self.print_os_env_var("PATH"),
+                "",  # just finish with an empty new line
+            ]
+
+        def assert_output(self, out, raw, _):
+            # pre-activation
+            assert out[0], raw
+            assert out[1] == "None", raw
+            assert out[2] == "None", raw
+            # post-activation
+            expected = self._creator.exe.parent / os.path.basename(sys.executable)
+            assert self.norm_path(out[4]) == self.norm_path(expected), raw
+            assert self.norm_path(out[5]) == self.norm_path(self._creator.dest).replace("\\\\", "\\"), raw
+            assert out[6] == self._creator.env_name
+            # Some attempts to test the prompt output print more than 1 line.
+            # So we need to check if the prompt exists on any of them.
+            prompt_text = f"({self._creator.env_name}) "
+            assert any(prompt_text in line for line in out[7:-5]), raw
+
+            assert out[-5] == "wrote pydoc_test.html", raw
+            content = tmp_path / "pydoc_test.html"
+            assert content.exists(), raw
+            # post deactivation, same as before
+            assert out[-4] == out[0], raw
+            assert out[-3] == "None", raw
+            assert out[-2] == "None", raw
+
+            # Check that the PATH is restored
+            assert out[3] == out[13], raw
+            # Check that PATH changed after activation
+            assert out[3] != out[8], raw
+
     activation_tester(Fish)
diff --git a/tests/unit/discovery/test_discovery.py b/tests/unit/discovery/test_discovery.py
index b163be62..239e604c 100644
--- a/tests/unit/discovery/test_discovery.py
+++ b/tests/unit/discovery/test_discovery.py
@@ -2,6 +2,7 @@ from __future__ import annotations
 
 import logging
 import os
+import subprocess
 import sys
 from argparse import Namespace
 from pathlib import Path
@@ -146,6 +147,61 @@ def test_returns_second_python_specified_when_more_than_one_is_specified_and_env
     assert result == mocker.sentinel.python_from_cli
 
 
+def test_absolute_path_does_not_exist(tmp_path):
+    """
+    Test that virtualenv does not fail when an absolute path that does not exist is provided.
+    """
+    # Create a command that uses an absolute path that does not exist
+    # and a valid python executable.
+    command = [
+        sys.executable,
+        "-m",
+        "virtualenv",
+        "-p",
+        "/this/path/does/not/exist",
+        "-p",
+        sys.executable,
+        str(tmp_path / "dest"),
+    ]
+
+    # Run the command
+    process = subprocess.run(
+        command,
+        capture_output=True,
+        text=True,
+        check=False,
+    )
+
+    # Check that the command was successful
+    assert process.returncode == 0, process.stderr
+
+
+def test_absolute_path_does_not_exist_fails(tmp_path):
+    """
+    Test that virtualenv fails when a single absolute path that does not exist is provided.
+    """
+    # Create a command that uses an absolute path that does not exist
+    command = [
+        sys.executable,
+        "-m",
+        "virtualenv",
+        "-p",
+        "/this/path/does/not/exist",
+        str(tmp_path / "dest"),
+    ]
+
+    # Run the command
+    process = subprocess.run(
+        command,
+        capture_output=True,
+        text=True,
+        check=False,
+    )
+
+    # Check that the command failed
+    assert process.returncode != 0, process.stderr
+
+
 @pytest.mark.usefixtures("mock_get_interpreter")
 def test_returns_first_python_specified_when_no_env_var_is_specified(mocker, monkeypatch, session_app_data):
     monkeypatch.delenv("VIRTUALENV_PYTHON", raising=False)