Repository: pypa/virtualenv
Pull request #2902 (2025-07-05 02:06:52)

Discover uv-managed Python installations
Closes https://github.com/pypa/virtualenv/issues/2901.

This PR updates the interpreter discovery logic to look for uv-managed Python installations.

They currently take priority over Python interpreters in `PATH` but I'm not 100% sure that makes sense so let me know what you think!

An alternative to this PR that I found after starting work for this PR is implementing a [discovery plugin](https://virtualenv.pypa.io/en/latest/extend.html#python-discovery). I see `pyenv` discovery has not been implemented as a builtin discovery option, so let me know if there are good reasons to also keep uv-managed Pythons outside of virtualenv's scope.

### 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
- [x] updated/extended the documentation


edgarrmondragon: > We need to fix the CI first though, can you open a parallel PR for that?

Saw you started https://github.com/pypa/virtualenv/pull/2904. Want to take a stab in a different PR?
gaborbernat: Yeah I started but solution turned out something complicated... At some point might get back to it. 
esafak: > We need to fix the CI first though, can you open a parallel PR for that?

What's the problem with it?
edgarrmondragon: Might not be needed anymore: https://github.com/astral-sh/uv/releases/tag/0.8.0.
gaborbernat: > Might not be needed anymore: [astral-sh/uv@`0.8.0` (release)](https://github.com/astral-sh/uv/releases/tag/0.8.0).

Can still be helpful as users might not add that folder to their `PATH` 😊 

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 17e32567..4299c675 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -24,7 +24,7 @@ repos:
     hooks:
       - id: pyproject-fmt
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: "v0.12.2"
+    rev: "v0.12.3"
     hooks:
       - id: ruff-format
       - id: ruff
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/docs/changelog/2901.feature.rst b/docs/changelog/2901.feature.rst
new file mode 100644
index 00000000..d04b17c8
--- /dev/null
+++ b/docs/changelog/2901.feature.rst
@@ -0,0 +1,1 @@
+Discover uv-managed Python installations
diff --git a/docs/user_guide.rst b/docs/user_guide.rst
index e8081c82..63412327 100644
--- a/docs/user_guide.rst
+++ b/docs/user_guide.rst
@@ -102,6 +102,8 @@ format is either:
    - If we're on Windows look into the Windows registry, and check if we see any registered Python implementations that
      match the specification. This is in line with expectation laid out inside
      `PEP-514 <https://www.python.org/dev/peps/pep-0514/>`_
+   - If `uv-managed <https://docs.astral.sh/uv/concepts/python-versions/>`_ Python installations are available, use the
+     first one that matches the specification.
    - Try to discover a matching python executable within the folders enumerated on the ``PATH`` environment variable.
      In this case we'll try to find an executable that has a name roughly similar to the specification (for exact logic,
      please see the implementation code).
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..3a05f12d 100644
--- a/src/virtualenv/discovery/builtin.py
+++ b/src/virtualenv/discovery/builtin.py
@@ -7,6 +7,8 @@ from contextlib import suppress
 from pathlib import Path
 from typing import TYPE_CHECKING
 
+from platformdirs import user_data_path
+
 from virtualenv.info import IS_WIN, fs_path_id
 
 from .discover import Discover
@@ -116,8 +118,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)
@@ -146,6 +147,20 @@ def propose_interpreters(  # noqa: C901, PLR0912, PLR0915
                     continue
                 tested_exes.add(exe_id)
                 yield interpreter, True
+
+        # 4. otherwise try uv-managed python (~/.local/share/uv/python or platform equivalent)
+        if uv_python_dir := os.getenv("UV_PYTHON_INSTALL_DIR"):
+            uv_python_path = Path(uv_python_dir).expanduser()
+        elif xdg_data_home := os.getenv("XDG_DATA_HOME"):
+            uv_python_path = Path(xdg_data_home).expanduser() / "uv" / "python"
+        else:
+            uv_python_path = user_data_path("uv") / "python"
+
+        for exe_path in uv_python_path.glob("*/bin/python"):
+            interpreter = PathPythonInfo.from_exe(str(exe_path), app_data, raise_on_error=False, env=env)
+            if interpreter is not None:
+                yield interpreter, True
+
     # finally just find on path, the path order matters (as the candidates are less easy to control by end user)
     find_candidates = path_exe_finder(spec)
     for pos, path in enumerate(get_paths(env)):


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..d71ed72e 100644
--- a/tests/unit/discovery/test_discovery.py
+++ b/tests/unit/discovery/test_discovery.py
@@ -2,9 +2,11 @@ from __future__ import annotations
 
 import logging
 import os
+import subprocess
 import sys
 from argparse import Namespace
 from pathlib import Path
+from unittest.mock import patch
 from uuid import uuid4
 
 import pytest
@@ -82,6 +84,58 @@ def test_relative_path(session_app_data, monkeypatch):
     assert result is not None
 
 
+def test_uv_python(monkeypatch, tmp_path_factory, mocker):
+    monkeypatch.delenv("UV_PYTHON_INSTALL_DIR", raising=False)
+    monkeypatch.delenv("XDG_DATA_HOME", raising=False)
+    monkeypatch.setenv("PATH", "")
+    mocker.patch.object(PythonInfo, "satisfies", return_value=False)
+
+    # UV_PYTHON_INSTALL_DIR
+    uv_python_install_dir = tmp_path_factory.mktemp("uv_python_install_dir")
+    with patch("virtualenv.discovery.builtin.PathPythonInfo.from_exe") as mock_from_exe, monkeypatch.context() as m:
+        m.setenv("UV_PYTHON_INSTALL_DIR", str(uv_python_install_dir))
+
+        get_interpreter("python", [])
+        mock_from_exe.assert_not_called()
+
+        bin_path = uv_python_install_dir.joinpath("some-py-impl", "bin")
+        bin_path.mkdir(parents=True)
+        bin_path.joinpath("python").touch()
+        get_interpreter("python", [])
+        mock_from_exe.assert_called_once()
+        assert mock_from_exe.call_args[0][0] == str(bin_path / "python")
+
+    # XDG_DATA_HOME
+    xdg_data_home = tmp_path_factory.mktemp("xdg_data_home")
+    with patch("virtualenv.discovery.builtin.PathPythonInfo.from_exe") as mock_from_exe, monkeypatch.context() as m:
+        m.setenv("XDG_DATA_HOME", str(xdg_data_home))
+
+        get_interpreter("python", [])
+        mock_from_exe.assert_not_called()
+
+        bin_path = xdg_data_home.joinpath("uv", "python", "some-py-impl", "bin")
+        bin_path.mkdir(parents=True)
+        bin_path.joinpath("python").touch()
+        get_interpreter("python", [])
+        mock_from_exe.assert_called_once()
+        assert mock_from_exe.call_args[0][0] == str(bin_path / "python")
+
+    # User data path
+    user_data_path = tmp_path_factory.mktemp("user_data_path")
+    with patch("virtualenv.discovery.builtin.PathPythonInfo.from_exe") as mock_from_exe, monkeypatch.context() as m:
+        m.setattr("virtualenv.discovery.builtin.user_data_path", lambda x: user_data_path / x)
+
+        get_interpreter("python", [])
+        mock_from_exe.assert_not_called()
+
+        bin_path = user_data_path.joinpath("uv", "python", "some-py-impl", "bin")
+        bin_path.mkdir(parents=True)
+        bin_path.joinpath("python").touch()
+        get_interpreter("python", [])
+        mock_from_exe.assert_called_once()
+        assert mock_from_exe.call_args[0][0] == str(bin_path / "python")
+
+
 def test_discovery_fallback_fail(session_app_data, caplog):
     caplog.set_level(logging.DEBUG)
     builtin = Builtin(
@@ -146,6 +200,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)