Repository: wntrblm/nox (python)

Issue:
Property to access venv_backend
### How would this feature be useful?

There are several ways to set the backend, like environment variable, command line, and with a fall-through list. However, it's hard to tell which backend you actually have inside the noxfile. It looks like (from #795) it might not even be possible to just check `which("uv")` to see if uv is present reliably. So I think being able to get the venv_backend string back out with the final selection would be useful.

### Describe the solution you'd like

```python
    @property
    def venv_backend(self) -> str:
        try:
            venv = self.virtualenv
        except ValueError:
            return "none"
        if isinstance(venv, PassthroughEnv):
            return "none"
        if isinstance(venv, CondaEnv):
            return venv.conda_cmd
        if isinstance(venv, VirtualEnv):
            return venv.venv_backend
        raise ValueError("Unsupported environment")
```

Or, actually, probably making it an abstract property would be better.

### Describe alternatives you've considered

The `.virtualenv` property does give you the backend, but each backend is different and accessing the properties only defined on ProcessEnv subclasses doesn't really feel like public API.

I'm currently using:

```python
    if getattr(session.virtualenv, "venv_backend", "") != "uv":
        session.install("uv")
```

### Anything else?
It looks like `prog @ .` doesn't work for pip, so this will need to be based on the backend. :/

Pull request description:
feat: add venv_backend property
The venv property is also called `venv_backend` (matching the original property on the Venv subclass).

Closes #796.


Patch:
diff --git a/docs/config.rst b/docs/config.rst
index 74bb783..53ab693 100644
--- a/docs/config.rst
+++ b/docs/config.rst
@@ -167,7 +167,7 @@ You can also specify that the virtualenv should *always* be reused instead of re
     def tests(session):
         pass
 
-You are not limited to virtualenv, there is a selection of backends you can choose from as venv, conda, mamba, or virtualenv (default):
+You are not limited to virtualenv, there is a selection of backends you can choose from as venv, uv, conda, mamba, or virtualenv (default):
 
 .. code-block:: python
 
@@ -175,6 +175,11 @@ You are not limited to virtualenv, there is a selection of backends you can choo
     def tests(session):
         pass
 
+You can chain together optional backends with ``|``, such as ``uv|virtualenv``
+or ``mamba|conda``, and the first available backend will be selected. You
+cannot put anything after a backend that can't be missing like ``venv`` or
+``virtualenv``.
+
 Finally, custom backend parameters are supported:
 
 .. code-block:: python
@@ -183,6 +188,9 @@ Finally, custom backend parameters are supported:
     def tests(session):
         pass
 
+If you need to check to see which backend was selected, you can access it via
+``session.venv_backend``.
+
 
 Passing arguments into sessions
 -------------------------------
diff --git a/docs/usage.rst b/docs/usage.rst
index e32808d..f29dc42 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -165,6 +165,9 @@ Note that using this option does not change the backend for sessions where ``ven
 
 Backends that could be missing (``uv``, ``conda``, and ``mamba``) can have a fallback using ``|``, such as ``uv|virtualenv`` or ``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
+``session.venv_backend`` in your noxfile.
+
 .. _opt-force-venv-backend:
 
 Forcing the sessions backend
diff --git a/nox/sessions.py b/nox/sessions.py
index 2728b1f..83c3fdb 100644
--- a/nox/sessions.py
+++ b/nox/sessions.py
@@ -193,6 +193,14 @@ class Session:
             raise ValueError("A virtualenv has not been created for this session")
         return venv
 
+    @property
+    def venv_backend(self) -> str:
+        """The venv_backend selected."""
+        venv = self._runner.venv
+        if venv is None:
+            return "none"
+        return venv.venv_backend
+
     @property
     def python(self) -> str | Sequence[str] | bool | None:
         """The python version passed into ``@nox.session``."""
diff --git a/nox/virtualenv.py b/nox/virtualenv.py
index 6f4b609..2258a04 100644
--- a/nox/virtualenv.py
+++ b/nox/virtualenv.py
@@ -93,6 +93,13 @@ class ProcessEnv(abc.ABC):
         Returns True if the environment is new, and False if it was reused.
         """
 
+    @property
+    @abc.abstractmethod
+    def venv_backend(self) -> str:
+        """
+        Returns the string used to select this environment.
+        """
+
 
 def locate_via_py(version: str) -> str | None:
     """Find the Python executable using the Windows Launcher.
@@ -180,6 +187,10 @@ class PassthroughEnv(ProcessEnv):
         False since it's always reused."""
         return False
 
+    @property
+    def venv_backend(self) -> str:
+        return "none"
+
 
 class CondaEnv(ProcessEnv):
     """Conda environment management class.
@@ -303,6 +314,10 @@ class CondaEnv(ProcessEnv):
         except BaseException:  # pragma: no cover
             return True
 
+    @property
+    def venv_backend(self) -> str:
+        return self.conda_cmd
+
 
 class VirtualEnv(ProcessEnv):
     """Virtualenv management class.
@@ -341,7 +356,7 @@ class VirtualEnv(ProcessEnv):
         self.interpreter = interpreter
         self._resolved: None | str | InterpreterNotFound = None
         self.reuse_existing = reuse_existing
-        self.venv_backend = venv_backend
+        self._venv_backend = venv_backend
         self.venv_params = venv_params or []
         if venv_backend not in {"virtualenv", "venv", "uv"}:
             msg = f"venv_backend {venv_backend} not recognized"
@@ -544,6 +559,10 @@ class VirtualEnv(ProcessEnv):
 
         return True
 
+    @property
+    def venv_backend(self) -> str:
+        return self._venv_backend
+
 
 ALL_VENVS: dict[str, Callable[..., ProcessEnv]] = {
     "conda": functools.partial(CondaEnv, conda_cmd="conda"),


Test patch:
diff --git a/tests/test_sessions.py b/tests/test_sessions.py
index ff09e42..ef17bd5 100644
--- a/tests/test_sessions.py
+++ b/tests/test_sessions.py
@@ -139,6 +139,8 @@ class TestSession:
         with pytest.raises(ValueError, match="virtualenv"):
             _ = session.virtualenv
 
+        assert session.venv_backend == "none"
+
     def test_interactive(self):
         session, runner = self.make_session_and_runner()
 
@@ -645,6 +647,8 @@ class TestSession:
 
         session = SessionNoSlots(runner=runner)
 
+        assert session.venv_backend == "venv"
+
         with mock.patch.object(session, "_run", autospec=True) as run:
             session.install("requests", "urllib3")
             run.assert_called_once_with(
diff --git a/tests/test_virtualenv.py b/tests/test_virtualenv.py
index 6af872b..69dd6c5 100644
--- a/tests/test_virtualenv.py
+++ b/tests/test_virtualenv.py
@@ -14,6 +14,7 @@
 
 from __future__ import annotations
 
+import functools
 import os
 import re
 import shutil
@@ -52,14 +53,13 @@ class TextProcessResult(NamedTuple):
 def make_one(tmpdir):
     def factory(*args, venv_backend: str = "virtualenv", **kwargs):
         location = tmpdir.join("venv")
-        if venv_backend in {"mamba", "conda"}:
-            venv = nox.virtualenv.CondaEnv(
-                location.strpath, *args, conda_cmd=venv_backend, **kwargs
-            )
-        else:
-            venv = nox.virtualenv.VirtualEnv(
-                location.strpath, *args, venv_backend=venv_backend, **kwargs
+        try:
+            venv_fn = nox.virtualenv.ALL_VENVS[venv_backend]
+        except KeyError:
+            venv_fn = functools.partial(
+                nox.virtualenv.VirtualEnv, venv_backend=venv_backend
             )
+        venv = venv_fn(location.strpath, *args, **kwargs)
         return (venv, location)
 
     return factory
@@ -490,13 +490,21 @@ def test_stale_environment(make_one, frm, to, result, monkeypatch):
     monkeypatch.setenv("NOX_ENABLE_STALENESS_CHECK", "1")
     venv, _ = make_one(reuse_existing=True, venv_backend=frm)
     venv.create()
+    assert venv.venv_backend == frm
 
     venv, _ = make_one(reuse_existing=True, venv_backend=to)
     reused = venv._check_reused_environment_type()
+    assert venv.venv_backend == to
 
     assert reused == result
 
 
+def test_passthrough_environment_venv_backend(make_one):
+    venv, _ = make_one(venv_backend="none")
+    venv.create()
+    assert venv.venv_backend == "none"
+
+
 @has_uv
 def test_create_reuse_stale_virtualenv_environment(make_one, monkeypatch):
     monkeypatch.setenv("NOX_ENABLE_STALENESS_CHECK", "1")