Repository: wntrblm/nox (python)

Issue:
URL dependencies are sometimes silently ignored in PEP 723 noxfiles
### Current Behavior

URL dependencies in a noxfile's PEP 723 `dependencies` are silently ignored in some cases.

### Expected Behavior

Nox should not ignore any PEP 723 dependencies.

### Steps To Reproduce

Minimal reproducer:

1.  `noxfile.py`:

    ```python
    # /// script
    # requires-python = ">= 3.9"
    # dependencies = [
    #     "nox @ git+https://github.com/wntrblm/nox.git@2024.10.09",
    # ]
    # ///

    import importlib.metadata

    import nox


    @nox.session(python=False)
    def a(session: nox.Session) -> None:
        print(importlib.metadata.version("nox"))


    if __name__ == "__main__":
        import nox.__main__

        nox.__main__.main()
    ```

2.  Run `nox`

    Behavior:

    ```console
    $ nox
    nox > Running session a
    2025.2.9
    nox > Session a was successful.
    ```

    Expected behavior:

    ```console
    $ nox
    nox > Running session a
    2024.10.09
    nox > Session a was successful.
    ```

    For comparison:

    ```console
    $ pipx run noxfile.py
    nox > Running session a
    2024.10.9
    nox > Session a was successful.
    ```

    ```console
    $ uv run -s noxfile.py
    nox > Running session a
    2024.10.9
    nox > Session a was successful.
    ```

### Environment

```Markdown
- OS: Arch Linux
- Python: CPython 3.13.2
- Nox: 2025.2.9
```

### Anything else?

Whether Nox ignores a requirement depends on both the requirement and on the environment of the calling `nox`. For example, if we modify the example above:

```diff
 # dependencies = [
-#     "nox @ git+https://github.com/wntrblm/nox.git@2024.10.09",
+#     "nox[uv] @ git+https://github.com/wntrblm/nox.git@2024.10.09",
 # ]
```

then now `nox` will no longer ignore the requirement _if_ `nox` is installed a venv without uv, but `nox` will still ignore the requirement if uv is installed in the same venv as it.

Another example:

```python
# /// script
# requires-python = ">= 3.9"
# dependencies = [
#     "nox >= 2025.2.9",
#     "httpx @ git+https://github.com/encode/httpx@0.28.0",
# ]
# ///

import importlib.metadata

import nox


@nox.session(python=False)
def a(session: nox.Session) -> None:
    print(importlib.metadata.version("httpx"))


if __name__ == "__main__":
    nox.main()
```

With this one, Nox will ignore the httpx requirement if (for example) `httpx==0.28.1` is installed in the same venv as `nox`.

Pull request description:
Never ignore URL dependencies in PEP 723 noxfiles
Fixes #934.

W.r.t. performance: On uv (the default, if available), uv caches based on the URL. On virtualenv however, the URL will get pulled and built/installed every time.

Ideally, for the virtualenv case, nox would do its own caching on the URL, similar to what the uv backend, `uv run -s noxfile.py` and `pipx run noxfile.py` all do. I have not included that here since I wanted to keep the scope of this PR to the correctness issue. Caching can be added later if needed. URL dependencies in PEP 723 noxfiles are perhaps not a very common case anyway, so perhaps it is TBD whether it's worth it for Nox to build a serialization format for doing said caching?

Patch:
diff --git a/.github/action_helper.py b/.github/action_helper.py
index d2a6fce..e068547 100644
--- a/.github/action_helper.py
+++ b/.github/action_helper.py
@@ -33,7 +33,7 @@ def filter_version(version: str) -> str:
     return ".".join(version_parts[:2])
 
 
-def setup_action(input_: str) -> None:
+def setup_action(input_: str, *, self_version: str = "3.12") -> None:
     versions = [version.strip() for version in input_.split(",") if version.strip()]
 
     pypy_versions = [version for version in versions if version.startswith("pypy")]
@@ -60,15 +60,15 @@ def setup_action(input_: str) -> None:
     # other interpreters also define pythonX.Y symlinks.
     versions = pypy_versions + cpython_versions
 
-    # we want to install python 3.11 last to ease nox set-up
-    if "3.11" in cpython_versions_filtered:
-        index = cpython_versions_filtered.index("3.11")
+    # we want to install our own self version last to ease nox set-up
+    if self_version in cpython_versions_filtered:
+        index = cpython_versions_filtered.index(self_version)
         index = versions.index(cpython_versions[index])
         cpython_nox = versions.pop(index)
         versions.append(cpython_nox)
     else:
         # add this to install nox
-        versions.append("3.11")
+        versions.append(self_version)
 
     print(f"interpreters={json.dumps(versions)}")
 
diff --git a/action.yml b/action.yml
index e26e1fa..d1eb0c0 100644
--- a/action.yml
+++ b/action.yml
@@ -4,7 +4,7 @@ inputs:
   python-versions:
     description: "comma-separated list of python versions to install"
     required: false
-    default: "3.8, 3.9, 3.10, 3.11, 3.12, 3.13"
+    default: "3.9, 3.10, 3.11, 3.12, 3.13"
 branding:
   icon: package
   color: blue
@@ -15,7 +15,7 @@ runs:
     - uses: actions/setup-python@v5
       id: localpython
       with:
-        python-version: "3.8 - 3.12"
+        python-version: "3.9 - 3.13"
         update-environment: false
 
     - name: "Validate input"
diff --git a/docs/usage.rst b/docs/usage.rst
index ab9daea..18fc0f7 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -156,11 +156,6 @@ Note that using this option does not change the backend for sessions where ``ven
    as ``uv pip`` is used to install programs instead. If you need to manually
    interact with pip, you should install it with ``session.install("pip")``.
 
-   Also, uv tries very hard to cache, so you might want
-   ``--reinstall-package=<package>`` in your pip install command for the local
-   package, or preferably set ``tool.uv.reinstall-package`` in your
-   pyproject.toml/uv.toml.
-
 Backends that could be missing (``uv``, ``conda``, ``mamba``, and ``micromamba``) can have a fallback using ``|``, such as ``uv|virtualenv`` or ``micromamba|mamba|conda``. This will use the first item that is available on the users system.
 
 If you need to check to see which backend was selected, you can access it via
diff --git a/nox/_cli.py b/nox/_cli.py
index f1807c5..cc83a34 100644
--- a/nox/_cli.py
+++ b/nox/_cli.py
@@ -21,6 +21,7 @@ import os
 import shutil
 import subprocess
 import sys
+import urllib.parse
 from pathlib import Path
 from typing import TYPE_CHECKING, Any, NoReturn
 
@@ -106,10 +107,35 @@ def check_dependencies(dependencies: list[str]) -> bool:
             version = importlib.metadata.version(dep.name)
             if not dep.specifier.contains(version):
                 return False
+        if dep.url:
+            dist = importlib.metadata.distribution(dep.name)
+            if not check_url_dependency(dep.url, dist):
+                return False
 
     return True
 
 
+def check_url_dependency(dep_url: str, dist: importlib.metadata.Distribution) -> bool:
+    """
+    Check to see if a url matches an installed distribution object. Returns false if
+    this is not a clear match.
+    """
+
+    # The .origin property added in Python 3.13
+    origin = getattr(dist, "origin", None)
+    if origin is None:
+        return False
+
+    dep_purl = urllib.parse.urlparse(dep_url)
+
+    if hasattr(origin, "requested_revision"):
+        origin_purl = urllib.parse.urlparse(f"{origin.url}@{origin.requested_revision}")
+    else:
+        origin_purl = urllib.parse.urlparse(origin.url)
+
+    return dep_purl.netloc == origin_purl.netloc and dep_purl.path == origin_purl.path
+
+
 def run_script_mode(
     envdir: Path, *, reuse: bool, dependencies: list[str], venv_backend: str
 ) -> NoReturn:
diff --git a/nox/tasks.py b/nox/tasks.py
index 02762fe..1b9d499 100644
--- a/nox/tasks.py
+++ b/nox/tasks.py
@@ -29,7 +29,7 @@ from nox._resolver import CycleError
 from nox._version import InvalidVersionSpecifier, VersionCheckFailed, check_nox_version
 from nox.logger import logger
 from nox.manifest import WARN_PYTHONS_IGNORED, Manifest
-from nox.sessions import Result
+from nox.sessions import Result, Status
 
 if TYPE_CHECKING:
     import types
@@ -411,7 +411,10 @@ def print_summary(
     for result in results:
         name = result.session.friendly_name
         status = result.status.name.lower()
-        result.log(f"* {name}: {status}")
+        if result.status is Status.SKIPPED and result.reason:
+            result.log(f"* {name}: {status} ({result.reason})")
+        else:
+            result.log(f"* {name}: {status}")
 
     # Return the results that were sent to this function.
     return results
diff --git a/noxfile.py b/noxfile.py
index 81dda9e..6dc05cf 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -177,7 +177,7 @@ def _check_python_version(session: nox.Session) -> None:
 )
 def github_actions_default_tests(session: nox.Session) -> None:
     """Check default versions installed by the nox GHA Action"""
-    assert sys.version_info[:2] == (3, 11)
+    assert sys.version_info[:2] == (3, 12)
     _check_python_version(session)
 
 


Test patch:
diff --git a/tests/resources/noxfile_script_mode_url_req.py b/tests/resources/noxfile_script_mode_url_req.py
new file mode 100644
index 0000000..3a3bb3f
--- /dev/null
+++ b/tests/resources/noxfile_script_mode_url_req.py
@@ -0,0 +1,14 @@
+# /// script
+# dependencies = ["nox @ git+https://github.com/wntrblm/nox.git@2024.10.09"]
+# ///
+
+# The Nox version pinned above should be the second-most-recent version or older.
+
+import importlib.metadata
+
+import nox
+
+
+@nox.session(python=False)
+def example(session: nox.Session) -> None:
+    print(importlib.metadata.version("nox"))
diff --git a/tests/test__cli.py b/tests/test__cli.py
index e210862..a02e2d5 100644
--- a/tests/test__cli.py
+++ b/tests/test__cli.py
@@ -1,7 +1,11 @@
+from __future__ import annotations
+
+import dataclasses
 import importlib.metadata
 import importlib.util
 import sys
-from pathlib import Path
+import typing
+from types import SimpleNamespace
 
 import packaging.requirements
 import packaging.version
@@ -9,6 +13,9 @@ import pytest
 
 import nox._cli
 
+if typing.TYPE_CHECKING:
+    from pathlib import Path
+
 
 def test_get_dependencies() -> None:
     if importlib.util.find_spec("tox") is None:
@@ -99,3 +106,62 @@ def test_invalid_backend_inline(
 
     with pytest.raises(ValueError, match="Expected venv_backend one of"):
         nox._cli.main()
+
+
+@dataclasses.dataclass
+class FakeDistribution:
+    origin: SimpleNamespace | None
+
+
+def test_url_dependency() -> None:
+    assert nox._cli.check_url_dependency(
+        "https://github.com/a/package",
+        FakeDistribution(origin=SimpleNamespace(url="https://github.com/a/package")),  # type: ignore[arg-type]
+    )
+    assert not nox._cli.check_url_dependency(
+        "https://github.com/a/package",
+        FakeDistribution(origin=None),  # type: ignore[arg-type]
+    )
+    assert nox._cli.check_url_dependency(
+        "https://github.com/a/package@v1.2.3",
+        FakeDistribution(  # type: ignore[arg-type]
+            origin=SimpleNamespace(
+                url="https://github.com/a/package", requested_revision="v1.2.3"
+            )
+        ),
+    )
+
+
+def test_dependencies_with_url(monkeypatch: pytest.MonkeyPatch) -> None:
+    monkeypatch.setattr(nox._cli, "get_dependencies", lambda x: [x])
+    monkeypatch.setattr(
+        importlib.metadata,
+        "distribution",
+        lambda _: FakeDistribution(
+            SimpleNamespace(
+                url="https://github.com/wntrblm/nox", requested_revision="2024.10.09"
+            )
+        ),
+    )
+
+    assert nox._cli.check_dependencies(
+        ["nox @ git+https://github.com/wntrblm/nox@2024.10.09"]
+    )
+    assert not nox._cli.check_dependencies(
+        ["nox @ git+https://github.com/wntrblm/nox@2024.10.10"]
+    )
+    assert not nox._cli.check_dependencies(
+        ["nox @ git+https://github.com/other/nox@2024.10.09"]
+    )
+
+
+def test_dependencies_with_version(monkeypatch: pytest.MonkeyPatch) -> None:
+    monkeypatch.setattr(nox._cli, "get_dependencies", lambda x: [x])
+    monkeypatch.setattr(
+        importlib.metadata, "version", lambda x: {"nox": "2024.10.09", "uv": "0.4.5"}[x]
+    )
+
+    assert nox._cli.check_dependencies(["nox", "uv"])
+    assert nox._cli.check_dependencies(["nox==2024.10.09", "uv>=0.4"])
+    assert nox._cli.check_dependencies(["nox<=2025", "uv~=0.4.2"])
+    assert not nox._cli.check_dependencies(["nox==2024.10.10", "uv"])
diff --git a/tests/test_action_helper.py b/tests/test_action_helper.py
index 9995e9f..7237798 100644
--- a/tests/test_action_helper.py
+++ b/tests/test_action_helper.py
@@ -48,17 +48,18 @@ VALID_VERSION_LISTS = {
         "3.9",
         "3.10",
         "3.11",
+        "3.12",
     ],
-    "": ["3.11"],
-    "~3.12.0-0": ["~3.12.0-0", "3.11"],
-    "3.11.4": ["3.11.4"],
-    "3.9-dev,pypy3.9-nightly": ["pypy3.9-nightly", "3.9-dev", "3.11"],
-    "3.11, 3.10, 3.9, 3.8": ["3.10", "3.9", "3.8", "3.11"],
+    "": ["3.12"],
+    "~3.12.0-0": ["~3.12.0-0"],
+    "3.11.4": ["3.11.4", "3.12"],
+    "3.9-dev,pypy3.9-nightly": ["pypy3.9-nightly", "3.9-dev", "3.12"],
+    "3.12, 3.11, 3.10, 3.9": ["3.11", "3.10", "3.9", "3.12"],
     ",".join(f"3.{minor}" for minor in range(20)): [
         f"3.{minor}"
-        for i, minor in enumerate(minor_ for minor_ in range(20) if minor_ != 11)
+        for i, minor in enumerate(minor_ for minor_ in range(20) if minor_ != 12)
     ]
-    + ["3.11"],
+    + ["3.12"],
 }
 
 
diff --git a/tests/test_main.py b/tests/test_main.py
index 8334167..0cf60e6 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -1071,3 +1071,25 @@ def test_noxfile_no_script_mode(monkeypatch: pytest.MonkeyPatch) -> None:
     )
     assert job.returncode == 1
     assert "No module named 'cowsay'" in job.stderr
+
+
+def test_noxfile_script_mode_url_req() -> None:
+    job = subprocess.run(
+        [
+            sys.executable,
+            "-m",
+            "nox",
+            "-f",
+            Path(RESOURCES) / "noxfile_script_mode_url_req.py",
+            "-s",
+            "example",
+        ],
+        check=False,
+        capture_output=True,
+        text=True,
+        encoding="utf-8",
+    )
+    print(job.stdout)
+    print(job.stderr)
+    assert job.returncode == 0
+    assert job.stdout.rstrip() == "2024.10.9"
diff --git a/tests/test_tasks.py b/tests/test_tasks.py
index 5d25db1..b223d15 100644
--- a/tests/test_tasks.py
+++ b/tests/test_tasks.py
@@ -695,23 +695,48 @@ def test_print_summary_one_result() -> None:
 
 
 def test_print_summary() -> None:
-    results = [
-        sessions.Result(
-            session=typing.cast(
-                sessions.SessionRunner, argparse.Namespace(friendly_name="foo")
+    with mock.patch.object(sessions.Result, "log") as mock_log:
+        results = [
+            sessions.Result(
+                session=typing.cast(
+                    sessions.SessionRunner,
+                    argparse.Namespace(friendly_name="foo"),
+                ),
+                status=sessions.Status.SUCCESS,
             ),
-            status=sessions.Status.SUCCESS,
-        ),
-        sessions.Result(
-            session=typing.cast(
-                sessions.SessionRunner, argparse.Namespace(friendly_name="bar")
+            sessions.Result(
+                session=typing.cast(
+                    sessions.SessionRunner,
+                    argparse.Namespace(friendly_name="bar"),
+                ),
+                status=sessions.Status.FAILED,
             ),
-            status=sessions.Status.FAILED,
-        ),
-    ]
-    with mock.patch.object(sessions.Result, "log", autospec=True) as log:
+            sessions.Result(
+                session=typing.cast(
+                    sessions.SessionRunner,
+                    argparse.Namespace(friendly_name="baz"),
+                ),
+                status=sessions.Status.SKIPPED,
+            ),
+            sessions.Result(
+                session=typing.cast(
+                    sessions.SessionRunner,
+                    argparse.Namespace(friendly_name="qux"),
+                ),
+                status=sessions.Status.SKIPPED,
+                reason="something reason",
+            ),
+        ]
+
         answer = tasks.print_summary(results, argparse.Namespace())
-        assert log.call_count == 2
+
+        assert mock_log.call_count == 4
+        calls = mock_log.call_args_list
+        assert calls[0][0][0] == "* foo: success"
+        assert calls[1][0][0] == "* bar: failed"
+        assert calls[2][0][0] == "* baz: skipped"
+        assert calls[3][0][0] == "* qux: skipped (something reason)"
+
     assert answer is results