Repository: pypa/virtualenv
Pull request #2906 (2025-07-16 02:14:49)

Add warning for incorrect usage of Nushell activation script
Unlike the other shells, the nushell activation script is not sourced. Users coming from other shells, like myself, may find the correct usage (`overlay use`) unexpected and waste time trying to solve the issue, so we will present them with a warning if they source it.

Many thanks to @Bahex for suggesting the solution in https://github.com/nushell/nushell/discussions/16182

- [ ] ran the linter to address style issues (`tox -e fix`)
- [x] wrote descriptive pull request text
- [ ] ensured there are test(s) validating the fix
- [x] added news fragment in `docs/changelog` folder
- [ ] updated/extended the documentation



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/changelog/nushell_activation.feature.rst b/docs/changelog/nushell_activation.feature.rst
new file mode 100644
index 00000000..6ffa0451
--- /dev/null
+++ b/docs/changelog/nushell_activation.feature.rst
@@ -0,0 +1,1 @@
+Warn on incorrect invocation of Nushell activation script - by :user:`esafak`.
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/activation/nushell/activate.nu b/src/virtualenv/activation/nushell/activate.nu
index 4da1c8c0..1d7ff54f 100644
--- a/src/virtualenv/activation/nushell/activate.nu
+++ b/src/virtualenv/activation/nushell/activate.nu
@@ -6,6 +6,18 @@
 # but then simply `deactivate` won't work because it is just an alias to hide
 # the "activate" overlay. You'd need to call `overlay hide foo` manually.
 
+# Warn users who accidentally 'source' it
+module warning {
+	export-env {
+		const file = path self
+		error make -u {
+			msg: $"`($file | path basename)` is meant to be used with `overlay use`, not `source`"
+		}
+	}
+}
+
+use warning
+
 export-env {
     def is-string [x] {
         ($x | describe) == 'string'
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)