Repository: wntrblm/nox (python)

Issue:
Nox installs Python using `uv` with non-uv backend
### Current Behavior

Nox installs Python using `uv` with non-uv backend. If uv is installed on the system.

### Expected Behavior

Don't use `uv` to install Python with non-uv backend.

### Steps To Reproduce

noxfile.py:

```python
import nox

@nox.session(python="3.8")
def lint(session):
    ...
```

Invoke `nox -s lint`.

If Python 3.8 is **not installed globally**:

```plaintext
nox > Running session lint
Installed Python 3.8.20 in 2m 20s
 + cpython-3.8.20-windows-x86_64-none
nox > Creating virtual environment (virtualenv) using python3.8 in .nox\lint
nox > Command ...\.venv\Scripts\python.exe -m virtualenv '...\.nox\lint' -p python3.8 failed with exit code 1:
RuntimeError: failed to find interpreter for Builtin discover of python_spec='python3.8'
nox > Session lint failed.
```

If Python 3.8 is **installed globally**.

```plaintext
nox > Running session lint
Installed Python 3.8.20 in 3m 55s
 + cpython-3.8.20-windows-x86_64-none
nox > Creating virtual environment (virtualenv) using python3.8 in .nox\lint
nox > Session lint was successful.
```

Nox installed Python using `uv`. But the Nox session virtual environment uses the globally installed version of Python. Because `virtualenv` is not compatible with `uv`.


### Environment

```Markdown
- OS: Windows 10
- Python: 3.11
- Nox: 2024.10.9
```


### Anything else?

_No response_

Pull request:
Use `uv python install` only with uv backend
fix #899

Patch:
diff --git a/nox/sessions.py b/nox/sessions.py
index b38fce3..07a7881 100644
--- a/nox/sessions.py
+++ b/nox/sessions.py
@@ -193,7 +193,7 @@ class Session:
         return self._runner.friendly_name
 
     @property
-    def env(self) -> dict[str, str]:
+    def env(self) -> dict[str, str | None]:
         """A dictionary of environment variables to pass into all commands."""
         return self.virtualenv.env
 
@@ -618,9 +618,14 @@ class Session:
             args = (nox.virtualenv.UV, *args[1:])
 
         # Combine the env argument with our virtualenv's env vars.
+        env = env or {}
+        env = {**self.env, **env}
         if include_outer_env:
-            overlay_env = env or {}
-            env = {**self.env, **overlay_env}
+            env = {**os.environ, **env}
+        if self.virtualenv.bin_paths:
+            env["PATH"] = os.pathsep.join(
+                [*self.virtualenv.bin_paths, env.get("PATH") or ""]
+            )
 
         # If --error-on-external-run is specified, error on external programs.
         if self._runner.global_config.error_on_external_run and external is None:
diff --git a/nox/virtualenv.py b/nox/virtualenv.py
index 3048db3..38fc1bd 100644
--- a/nox/virtualenv.py
+++ b/nox/virtualenv.py
@@ -161,19 +161,8 @@ class ProcessEnv(abc.ABC):
         self._bin_paths = None if bin_paths is None else list(bin_paths)
         self._reused = False
 
-        # Filter envs now so `.env` is dict[str, str] (easier to use)
-        # even though .command's env supports None.
-        env = env or {}
-        env = {**os.environ, **env}
-        self.env = {k: v for k, v in env.items() if v is not None}
-
-        for key in _BLACKLISTED_ENV_VARS:
-            self.env.pop(key, None)
-
-        if self.bin_paths:
-            self.env["PATH"] = os.pathsep.join(
-                [*self.bin_paths, self.env.get("PATH", "")]
-            )
+        # .command's env supports None, meaning don't include value even if in parent
+        self.env = {**{k: None for k in _BLACKLISTED_ENV_VARS}, **(env or {})}
 
     @property
     def bin_paths(self) -> list[str] | None:
@@ -229,6 +218,7 @@ def locate_via_py(version: str) -> str | None:
             check=False,
             text=True,
             capture_output=True,
+            encoding="utf-8",
         )
         if ret.returncode == 0 and ret.stdout:
             return ret.stdout.strip()
@@ -597,7 +587,9 @@ class VirtualEnv(ProcessEnv):
             self._resolved = cleaned_interpreter
             return self._resolved
 
-        if HAS_UV and UV_PYTHON_SUPPORT:
+        if (
+            self.venv_backend == "uv" and HAS_UV and UV_PYTHON_SUPPORT
+        ):  # pragma: nocover
             uv_python_success = uv_install_python(cleaned_interpreter)
             if uv_python_success:
                 self._resolved = cleaned_interpreter


Test patch:
diff --git a/tests/test_sessions.py b/tests/test_sessions.py
index f96e404..b875527 100644
--- a/tests/test_sessions.py
+++ b/tests/test_sessions.py
@@ -300,9 +300,12 @@ class TestSession:
             session.install("spam")
             session.run("spam", "eggs")
 
+        env = dict(os.environ)
+        env["PATH"] = os.pathsep.join(["/no/bin/for/you", env["PATH"]])
+
         run.assert_called_once_with(
             ("python", "-m", "pip", "install", "spam"),
-            **run_with_defaults(paths=mock.ANY, silent=True, env={}, external="error"),
+            **run_with_defaults(paths=mock.ANY, silent=True, env=env, external="error"),
         )
 
     def test_run_success(self) -> None:
@@ -342,10 +345,12 @@ class TestSession:
         assert result
         assert result.strip() == "1 3 5"
 
-    def test_by_default_all_invocation_env_vars_are_passed(self) -> None:
+    def test_by_default_all_invocation_env_vars_are_passed(
+        self, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        monkeypatch.setenv("I_SHOULD_BE_INCLUDED", "happy")
         session, runner = self.make_session_and_runner()
         assert runner.venv
-        runner.venv.env["I_SHOULD_BE_INCLUDED"] = "happy"
         runner.venv.env["I_SHOULD_BE_INCLUDED_TOO"] = "happier"
         runner.venv.env["EVERYONE_SHOULD_BE_INCLUDED_TOO"] = "happiest"
         result = session.run(
@@ -359,11 +364,13 @@ class TestSession:
         assert "happier" in result
         assert "happiest" in result
 
-    def test_no_included_invocation_env_vars_are_passed(self) -> None:
+    def test_no_included_invocation_env_vars_are_passed(
+        self, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        monkeypatch.setenv("I_SHOULD_NOT_BE_INCLUDED", "sad")
+        monkeypatch.setenv("AND_NEITHER_SHOULD_I", "unhappy")
         session, runner = self.make_session_and_runner()
         assert runner.venv
-        runner.venv.env["I_SHOULD_NOT_BE_INCLUDED"] = "sad"
-        runner.venv.env["AND_NEITHER_SHOULD_I"] = "unhappy"
         result = session.run(
             sys.executable,
             "-c",
@@ -377,6 +384,23 @@ class TestSession:
         assert "unhappy" not in result
         assert "happy" in result
 
+    def test_no_included_invocation_env_vars_are_passed_empty(
+        self, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        monkeypatch.setenv("I_SHOULD_NOT_BE_INCLUDED", "sad")
+        monkeypatch.setenv("AND_NEITHER_SHOULD_I", "unhappy")
+        session, runner = self.make_session_and_runner()
+        result = session.run(
+            sys.executable,
+            "-c",
+            "import os; print(os.environ)",
+            include_outer_env=False,
+            silent=True,
+        )
+        assert result
+        assert "sad" not in result
+        assert "unhappy" not in result
+
     def test_run_external_not_a_virtualenv(self) -> None:
         # Non-virtualenv sessions should always allow external programs.
         session, runner = self.make_session_and_runner()
diff --git a/tests/test_virtualenv.py b/tests/test_virtualenv.py
index e364ea8..9f1a6e7 100644
--- a/tests/test_virtualenv.py
+++ b/tests/test_virtualenv.py
@@ -275,9 +275,11 @@ def test_condaenv_detection(make_conda: Callable[..., tuple[CondaEnv, Path]]) ->
     conda = shutil.which("conda")
     assert conda
 
+    env = {k: v for k, v in {**os.environ, **venv.env}.items() if v is not None}
+
     proc_result = subprocess.run(
         [conda, "list"],
-        env=venv.env,
+        env=env,
         check=True,
         capture_output=True,
     )
@@ -336,9 +338,8 @@ def test_env(
 ) -> None:
     monkeypatch.setenv("SIGIL", "123")
     venv, _ = make_one()
-    assert venv.env["SIGIL"] == "123"
     assert len(venv.bin_paths) == 1
-    assert venv.bin_paths[0] in venv.env["PATH"]
+    assert venv.bin_paths[0] == venv.bin
     assert venv.bin_paths[0] not in os.environ["PATH"]
 
 
@@ -439,17 +440,19 @@ def test_create(
     venv, dir_ = make_one()
     venv.create()
 
-    assert "CONDA_PREFIX" not in venv.env
-    assert "NOT_CONDA_PREFIX" in venv.env
+    assert venv.env["CONDA_PREFIX"] is None
+    assert "NOT_CONDA_PREFIX" not in venv.env
 
     if IS_WINDOWS:
         assert dir_.joinpath("Scripts", "python.exe").exists()
         assert dir_.joinpath("Scripts", "pip.exe").exists()
         assert dir_.joinpath("Lib").exists()
+        assert str(dir_.joinpath("Scripts")) in venv.bin_paths
     else:
         assert dir_.joinpath("bin", "python").exists()
         assert dir_.joinpath("bin", "pip").exists()
         assert dir_.joinpath("lib").exists()
+        assert str(dir_.joinpath("bin")) in venv.bin_paths
 
     # Test running create on an existing environment. It should be deleted.
     dir_.joinpath("test.txt").touch()
@@ -561,6 +564,8 @@ def test_reuse_conda_environment(
 ) -> None:
     venv, _ = make_one(reuse_existing=True, venv_backend="conda")
     venv.create()
+    assert venv.bin_paths
+    assert venv.bin_paths[-1].endswith("bin")
 
     venv, _ = make_one(reuse_existing=True, venv_backend="conda")
     reused = not venv.create()