Repository: wntrblm/nox

Issue:
Having a `.python-version` or `.python-versions` file breaks Python version definition in Nox with uv backend
### Current Behavior

If there is a `.python-version` or `.python-versions` file in the project's root, uv will default to that after a `uv sync`, and ignore the Python version specified in the Noxfile.

### Expected Behavior

It should ignore these files and use the Python version defined by Nox.

### Steps To Reproduce

1. `uv init nox-uv`
2. `code nox-uv`
3. `uv add --dev "nox[uv]"`
4. `uv sync`
5. Create the following `noxfile.py` in the root:
```py
import nox

nox.options.default_venv_backend = "uv"


@nox.session(python=["3.8", "3.9"])
def py_ver(session: nox.Session) -> None:
    session.run_install(
        "uv",
        "sync",
        "--no-group=dev",
        env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location},
    )
    session.run("python", "--version")
```
6. `nox`
7. Notice how it'll run all sessions with Python 3.13.
```bash
❯ nox
nox > Running session py_ver-3.8
nox > Creating virtual environment (uv) using python3.8 in .nox/py_ver-3-8
nox > uv sync --no-group=dev
Using CPython 3.13.2
Removed virtual environment at: .nox/py_ver-3-8
Creating virtual environment at: .nox/py_ver-3-8
Resolved 13 packages in 0.92ms
Audited in 0.01ms
nox > python --version
Python 3.13.2
nox > Session py_ver-3.8 was successful.
nox > Running session py_ver-3.9
nox > Creating virtual environment (uv) using python3.9 in .nox/py_ver-3-9
nox > uv sync --no-group=dev
Using CPython 3.13.2
Removed virtual environment at: .nox/py_ver-3-9
Creating virtual environment at: .nox/py_ver-3-9
Resolved 13 packages in 1ms
Audited in 0.01ms
nox > python --version
Python 3.13.2
nox > Session py_ver-3.9 was successful.
nox > Ran multiple sessions:
nox > * py_ver-3.8: success
nox > * py_ver-3.9: success
```

### Environment

```Markdown
- OS: Ubuntu 24.04
- Python: 3.13
- Nox: 2025.2.9
```

### Anything else?

I'm following the official Nox recipe for uv found here:
https://nox.thea.codes/en/stable/cookbook.html#using-a-lockfile

I believe this may be solved by adapting the recipe.

Hints from issue comments:
@henryiii: You can add `--no-config`, though that will also ignore uv.toml. Thanks to @zanieb for the suggestion! We might want to add that to the recipe for now until there's a more fine-grained way to avoid replacing the venv.@MicaelJarniac: `--no-config` didn't seem to work.
```bash
❯ nox
nox > Running session py_ver-3.8
nox > Creating virtual environment (uv) using python3.8 in .nox/py_ver-3-8
nox > uv sync --no-group=dev --no-config
Using CPython 3.13.2
Removed virtual environment at: .nox/py_ver-3-8
Creating virtual environment at: .nox/py_ver-3-8
Resolved 13 packages in 1ms
Audited in 0.02ms
nox > python --version
Python 3.13.2
nox > Session py_ver-3.8 was successful.
nox > Running session py_ver-3.9
nox > Creating virtual environment (uv) using python3.9 in .nox/py_ver-3-9
nox > uv sync --no-group=dev --no-config
Using CPython 3.13.2
Removed virtual environment at: .nox/py_ver-3-9
Creating virtual environment at: .nox/py_ver-3-9
Resolved 13 packages in 1ms
Audited in 0.02ms
nox > python --version
Python 3.13.2
nox > Session py_ver-3.9 was successful.
nox > Ran multiple sessions:
nox > * py_ver-3.8: success
nox > * py_ver-3.9: success
```@henryiii: Is your `requires-python` correct? It will ignore the request if it doesn't match that, and uv sets it to `>=3.13` by default. I made this mistake the first time, too.

Another way to do it is to set it via `-p`, like this:

```python
@nox.session(python=["3.8", "3.9"])
def py_ver(session: nox.Session) -> None:
    session.run_install(
        "uv",
        "sync",
        "--no-default-groups",
        f"-p{session.virtualenv.location}",
        env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location},
    )
    session.run("python", "--version")
```@MicaelJarniac: It was a bad `requires-python` indeed, thanks!

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 23850f7..a370680 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -95,7 +95,7 @@ Documentation:
 * Added warning about uv and local packages by @henryiii in https://github.com/wntrblm/nox/pull/830
 * Fixed contribution guidelines by @Wurstnase in https://github.com/wntrblm/nox/pull/850
 * Fixed typos by @ember91 in https://github.com/wntrblm/nox/pull/839
-* Fixed typoss in cookbook by @hmd101 in https://github.com/wntrblm/nox/pull/837
+* Fixed typos in cookbook by @hmd101 in https://github.com/wntrblm/nox/pull/837
 * Added missing cookbook recipe snippet imports by @KasperZutterman in https://github.com/wntrblm/nox/pull/853
 * Cleaned up `dev` recipe by @henryiii in https://github.com/wntrblm/nox/pull/862
 * Added note about `uv reinstall` by @henryiii in https://github.com/wntrblm/nox/pull/863
diff --git a/docs/cookbook.rst b/docs/cookbook.rst
index d593b6b..ee57d3f 100644
--- a/docs/cookbook.rst
+++ b/docs/cookbook.rst
@@ -133,6 +133,8 @@ If you use a tool like ``uv`` to lock your dependencies, you can use that inside
             "uv",
             "sync",
             "--extra=test",
+            "--no-default-extras",
+            f"--python={session.virtualenv.location}",
             env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location},
         )
         session.run("pytest", *session.posargs)
diff --git a/nox/_option_set.py b/nox/_option_set.py
index 873a701..74d91a5 100644
--- a/nox/_option_set.py
+++ b/nox/_option_set.py
@@ -21,6 +21,7 @@ from __future__ import annotations
 import argparse
 import collections
 import functools
+import os
 from argparse import ArgumentError, ArgumentParser, Namespace
 from collections.abc import Callable, Iterable
 from typing import TYPE_CHECKING, Any, Literal
@@ -47,6 +48,7 @@ def __dir__() -> list[str]:
 
 
 av_opt_str = av.optional(av.instance_of(str))
+av_opt_path = av.optional(av.or_(av.instance_of(str), av.instance_of(os.PathLike)))
 av_opt_list_str = av.optional(
     av.deep_iterable(
         member_validator=av.instance_of(str),
@@ -59,7 +61,7 @@ av_bool = av.instance_of(bool)
 @attrs.define(slots=True, kw_only=True)
 class NoxOptions:
     default_venv_backend: None | str = attrs.field(validator=av_opt_str)
-    envdir: None | str = attrs.field(validator=av_opt_str)
+    envdir: None | str | os.PathLike[str] = attrs.field(validator=av_opt_path)
     error_on_external_run: bool = attrs.field(validator=av_bool)
     error_on_missing_interpreters: bool = attrs.field(validator=av_bool)
     force_venv_backend: None | str = attrs.field(validator=av_opt_str)
diff --git a/nox/_options.py b/nox/_options.py
index adb0e2a..7ce89ec 100644
--- a/nox/_options.py
+++ b/nox/_options.py
@@ -157,7 +157,7 @@ def _envdir_merge_func(
         noxfile_Args (NoxOptions): The options specified in the
             Noxfile.
     """
-    return command_args.envdir or noxfile_args.envdir or ".nox"
+    return os.fspath(command_args.envdir or noxfile_args.envdir or ".nox")
 
 
 def _reuse_venv_merge_func(


Test patch:
diff --git a/tests/test__option_set.py b/tests/test__option_set.py
index 3035d84..8d21a02 100644
--- a/tests/test__option_set.py
+++ b/tests/test__option_set.py
@@ -154,3 +154,6 @@ class TestOptionSet:
         options.sessions = ("testytest",)
         with pytest.raises(ValueError):  # noqa: PT011
             options.sessions = "testytest"
+
+        options.envdir = "envdir"
+        options.envdir = Path("envdir")
diff --git a/tests/test_main.py b/tests/test_main.py
index 16258f4..8334167 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -16,6 +16,7 @@ from __future__ import annotations
 
 import contextlib
 import os
+import shutil
 import subprocess
 import sys
 from importlib import metadata
@@ -527,12 +528,18 @@ def test_main_with_bad_session_names(
     assert session in stderr
 
 
+py39py310 = pytest.mark.skipif(
+    shutil.which("python3.10") is None or shutil.which("python3.9") is None,
+    reason="Python 3.9 and 3.10 required",
+)
+
+
 @pytest.mark.parametrize(
     ("sessions", "expected_order"),
     [
         (("g", "a", "d"), ("b", "c", "h", "g", "a", "e", "d")),
-        (("m",), ("k-3.9", "k-3.10", "m")),
-        (("n",), ("k-3.10", "n")),
+        pytest.param(("m",), ("k-3.9", "k-3.10", "m"), marks=py39py310),
+        pytest.param(("n",), ("k-3.10", "n"), marks=py39py310),
         (("v",), ("u(django='1.9')", "u(django='2.0')", "v")),
         (("w",), ("u(django='1.9')", "u(django='2.0')", "w")),
     ],
@@ -1022,7 +1029,8 @@ def test_symlink_sym_not(monkeypatch: pytest.MonkeyPatch) -> None:
     assert res.returncode == 1
 
 
-def test_noxfile_script_mode() -> None:
+def test_noxfile_script_mode(monkeypatch: pytest.MonkeyPatch) -> None:
+    monkeypatch.delenv("NOX_SCRIPT_MODE", raising=False)
     job = subprocess.run(
         [
             sys.executable,
@@ -1044,9 +1052,8 @@ def test_noxfile_script_mode() -> None:
     assert "hello_world" in job.stdout
 
 
-def test_noxfile_no_script_mode() -> None:
-    env = os.environ.copy()
-    env["NOX_SCRIPT_MODE"] = "none"
+def test_noxfile_no_script_mode(monkeypatch: pytest.MonkeyPatch) -> None:
+    monkeypatch.setenv("NOX_SCRIPT_MODE", "none")
     job = subprocess.run(
         [
             sys.executable,
@@ -1057,7 +1064,6 @@ def test_noxfile_no_script_mode() -> None:
             "-s",
             "example",
         ],
-        env=env,
         check=False,
         capture_output=True,
         text=True,
diff --git a/tests/test_tox_to_nox.py b/tests/test_tox_to_nox.py
index 1775146..6a3498c 100644
--- a/tests/test_tox_to_nox.py
+++ b/tests/test_tox_to_nox.py
@@ -15,6 +15,7 @@
 from __future__ import annotations
 
 import os
+import shutil
 import sys
 import textwrap
 from pathlib import Path
@@ -22,11 +23,14 @@ from typing import TYPE_CHECKING
 
 import pytest
 
+# Jinja2 might be missing
+tox_to_nox = pytest.importorskip("nox.tox_to_nox")
+
 if TYPE_CHECKING:
     from collections.abc import Callable
 
 
-tox_to_nox = pytest.importorskip("nox.tox_to_nox")
+pytestmark = pytest.mark.skipif(shutil.which("tox") is None, reason="Tox not available")
 
 PYTHON_VERSION = f"{sys.version_info.major}.{sys.version_info.minor}"
 PYTHON_VERSION_NODOT = PYTHON_VERSION.replace(".", "")