Repository: wntrblm/nox
Pull request #780 (2024-02-23 17:21:32)

feat: Allow configuring default backend with an environment variable
Closes https://github.com/wntrblm/nox/issues/776


Patch:
diff --git a/docs/conf.py b/docs/conf.py
index 7982f61..ff265aa 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -16,9 +16,9 @@ from __future__ import annotations
 import os
 import sys
 
-try:
-    import importlib.metadata as metadata
-except ImportError:
+if sys.version_info >= (3, 8):
+    from importlib import metadata
+else:
     import importlib_metadata as metadata
 
 # If extensions (or modules to document with autodoc) are in another directory,
diff --git a/docs/config.rst b/docs/config.rst
index 9311df5..74bb783 100644
--- a/docs/config.rst
+++ b/docs/config.rst
@@ -157,7 +157,7 @@ Use of :func:`session.install()` is deprecated without a virtualenv since it mod
     def tests(session):
         session.run("pip", "install", "nox")
 
-You can also specify that the virtualenv should *always* be reused instead of recreated every time:
+You can also specify that the virtualenv should *always* be reused instead of recreated every time unless ``--reuse-venv=never``:
 
 .. code-block:: python
 
@@ -432,7 +432,8 @@ The following options can be specified in the Noxfile:
 * ``nox.options.tags`` is equivalent to specifying :ref:`-t or --tags <opt-sessions-pythons-and-keywords>`.
 * ``nox.options.default_venv_backend`` is equivalent to specifying :ref:`-db or --default-venv-backend <opt-default-venv-backend>`.
 * ``nox.options.force_venv_backend`` is equivalent to specifying :ref:`-fb or --force-venv-backend <opt-force-venv-backend>`.
-* ``nox.options.reuse_existing_virtualenvs`` is equivalent to specifying :ref:`--reuse-existing-virtualenvs <opt-reuse-existing-virtualenvs>`. You can force this off by specifying ``--no-reuse-existing-virtualenvs`` during invocation.
+* ``nox.options.reuse_venv`` is equivalent to specifying :ref:`--reuse-venv <opt-reuse-venv>`. Preferred over using ``nox.options.reuse_existing_virtualenvs``.
+* ``nox.options.reuse_existing_virtualenvs`` is equivalent to specifying :ref:`--reuse-existing-virtualenvs <opt-reuse-existing-virtualenvs>`. You can force this off by specifying ``--no-reuse-existing-virtualenvs`` during invocation. Alias of ``nox.options.reuse_venv=yes|no``.
 * ``nox.options.stop_on_first_error`` is equivalent to specifying :ref:`--stop-on-first-error <opt-stop-on-first-error>`. You can force this off by specifying ``--no-stop-on-first-error`` during invocation.
 * ``nox.options.error_on_missing_interpreters`` is equivalent to specifying :ref:`--error-on-missing-interpreters <opt-error-on-missing-interpreters>`. You can force this off by specifying ``--no-error-on-missing-interpreters`` during invocation.
 * ``nox.options.error_on_external_run`` is equivalent to specifying :ref:`--error-on-external-run <opt-error-on-external-run>`. You can force this off by specifying ``--no-error-on-external-run`` during invocation.
diff --git a/docs/usage.rst b/docs/usage.rst
index dc1e03d..81431df 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -128,10 +128,17 @@ Changing the sessions default backend
 
 By default Nox uses ``virtualenv`` as the virtual environment backend for the sessions, but it also supports ``uv``, ``conda``, ``mamba``, and ``venv`` as well as no backend (passthrough to whatever python environment Nox is running on). You can change the default behaviour by using ``-db <backend>`` or ``--default-venv-backend <backend>``. Supported names are ``('none', 'uv', 'virtualenv', 'conda', 'mamba', 'venv')``.
 
-.. code-block:: console
 
-    nox -db conda
-    nox --default-venv-backend conda
+.. tabs::
+
+   .. code-tab:: console CLI options
+
+         nox -db conda
+         nox --default-venv-backend conda
+
+   .. code-tab:: console Environment variables
+
+         NOX_DEFAULT_VENV_BACKEND=conda
 
 .. note::
 
@@ -139,7 +146,7 @@ By default Nox uses ``virtualenv`` as the virtual environment backend for the se
    programs be pre-installed. ``uv`` is distributed as a Python package
    and can be installed with the ``nox[uv]`` extra.
 
-You can also set this option in the Noxfile with ``nox.options.default_venv_backend``. In case both are provided, the commandline argument takes precedence.
+You can also set this option with the ``NOX_DEFAULT_VENV_BACKEND`` environment variable, or in the Noxfile with ``nox.options.default_venv_backend``. In case more than one is provided, the command line argument overrides the environment variable, which in turn overrides the Noxfile configuration.
 
 Note that using this option does not change the backend for sessions where ``venv_backend`` is explicitly set.
 
@@ -179,26 +186,40 @@ Finally note that the ``--no-venv`` flag is a shortcut for ``--force-venv-backen
     nox --no-venv
 
 .. _opt-reuse-existing-virtualenvs:
+.. _opt-reuse-venv:
 
 Re-using virtualenvs
 --------------------
 
-By default, Nox deletes and recreates virtualenvs every time it is run. This is usually fine for most projects and continuous integration environments as `pip's caching <https://pip.pypa.io/en/stable/cli/pip_install/#caching>`_ makes re-install rather quick. However, there are some situations where it is advantageous to reuse the virtualenvs between runs. Use ``-r`` or ``--reuse-existing-virtualenvs``:
+By default, Nox deletes and recreates virtualenvs every time it is run. This is
+usually fine for most projects and continuous integration environments as
+`pip's caching <https://pip.pypa.io/en/stable/cli/pip_install/#caching>`_ makes
+re-install rather quick.  However, there are some situations where it is
+advantageous to reuse the virtualenvs between runs.  Use ``-r`` or
+``--reuse-existing-virtualenvs`` or for fine-grained control use
+``--reuse-venv=yes|no|always|never``:
 
 .. code-block:: console
 
     nox -r
     nox --reuse-existing-virtualenvs
-
+    nox --reuse-venv=yes # preferred
 
 If the Noxfile sets ``nox.options.reuse_existing_virtualenvs``, you can override the Noxfile setting from the command line by using ``--no-reuse-existing-virtualenvs``.
+Similarly you can override ``nox.options.reuse_venvs`` from the Noxfile via the command line by using ``--reuse-venv=yes|no|always|never``.
+
+.. note::
+
+    ``--reuse-existing-virtualenvs`` is a alias for ``--reuse-venv=yes`` and ``--no-reuse-existing-virtualenvs`` is an alias for ``--reuse-venv=no``.
 
-Additionally, you can skip the re-installation of packages when a virtualenv is reused. Use ``-R`` or ``--reuse-existing-virtualenvs --no-install``:
+Additionally, you can skip the re-installation of packages when a virtualenv is reused.
+Use ``-R`` or ``--reuse-existing-virtualenvs --no-install`` or ``--reuse-venv=yes --no-install``:
 
 .. code-block:: console
 
     nox -R
     nox --reuse-existing-virtualenvs --no-install
+    nox --reuse-venv=yes --no-install
 
 The ``--no-install`` option causes the following session methods to return early:
 
@@ -206,7 +227,10 @@ The ``--no-install`` option causes the following session methods to return early
 - :func:`session.conda_install <nox.sessions.Session.conda_install>`
 - :func:`session.run_install <nox.sessions.Session.run_install>`
 
-This option has no effect if the virtualenv is not being reused.
+The ``never`` and ``always`` options in ``--reuse-venv`` gives you more fine-grained control
+as it ignores when a ``@nox.session`` has ``reuse_venv=True|False`` defined.
+
+These options have no effect if the virtualenv is not being reused.
 
 .. _opt-running-extra-pythons:
 
diff --git a/nox/_option_set.py b/nox/_option_set.py
index e85c574..0ade4ed 100644
--- a/nox/_option_set.py
+++ b/nox/_option_set.py
@@ -21,7 +21,7 @@ from __future__ import annotations
 import argparse
 import collections
 import functools
-from argparse import ArgumentError as ArgumentError
+from argparse import ArgumentError as ArgumentError  # noqa: PLC0414
 from argparse import ArgumentParser, Namespace
 from collections.abc import Callable, Iterable
 from typing import Any
diff --git a/nox/_options.py b/nox/_options.py
index 7c4b89e..1096c4e 100644
--- a/nox/_options.py
+++ b/nox/_options.py
@@ -27,6 +27,13 @@ import argcomplete
 from nox import _option_set
 from nox.tasks import discover_manifest, filter_manifest, load_nox_module
 
+if sys.version_info < (3, 8):
+    from typing_extensions import Literal
+else:
+    from typing import Literal
+
+ReuseVenvType = Literal["no", "yes", "never", "always"]
+
 """All of Nox's configuration options."""
 
 options = _option_set.OptionSet(
@@ -128,10 +135,8 @@ def _force_venv_backend_merge_func(
             raise ValueError(
                 "You can not use `--no-venv` with a non-none `--force-venv-backend`"
             )
-        else:
-            return "none"
-    else:
-        return command_args.force_venv_backend or noxfile_args.force_venv_backend  # type: ignore[no-any-return]
+        return "none"
+    return command_args.force_venv_backend or noxfile_args.force_venv_backend  # type: ignore[no-any-return]
 
 
 def _envdir_merge_func(
@@ -148,6 +153,30 @@ def _envdir_merge_func(
     return command_args.envdir or noxfile_args.envdir or ".nox"
 
 
+def _reuse_venv_merge_func(
+    command_args: argparse.Namespace, noxfile_args: argparse.Namespace
+) -> ReuseVenvType:
+    """Merge reuse_venv from command args and Noxfile while maintaining
+    backwards compatibility with reuse_existing_virtualenvs. Default is "no".
+
+    Args:
+        command_args (_option_set.Namespace): The options specified on the
+            command-line.
+        noxfile_Args (_option_set.Namespace): The options specified in the
+            Noxfile.
+    """
+    # back-compat scenario with no_reuse_existing_virtualenvs/reuse_existing_virtualenvs
+    if command_args.no_reuse_existing_virtualenvs:
+        return "no"
+    if (
+        command_args.reuse_existing_virtualenvs
+        or noxfile_args.reuse_existing_virtualenvs
+    ):
+        return "yes"
+    # regular option behavior
+    return command_args.reuse_venv or noxfile_args.reuse_venv or "no"
+
+
 def default_env_var_list_factory(env_var: str) -> Callable[[], list[str] | None]:
     """Looks at the env var to set the default value for a list of env vars.
 
@@ -199,12 +228,22 @@ def _force_pythons_finalizer(
 
 
 def _R_finalizer(value: bool, args: argparse.Namespace) -> bool:
-    """Propagate -R to --reuse-existing-virtualenvs and --no-install."""
+    """Propagate -R to --reuse-existing-virtualenvs and --no-install and --reuse-venv=yes."""
     if value:
+        args.reuse_venv = "yes"
         args.reuse_existing_virtualenvs = args.no_install = value
     return value
 
 
+def _reuse_existing_virtualenvs_finalizer(
+    value: bool, args: argparse.Namespace
+) -> bool:
+    """Propagate --reuse-existing-virtualenvs to --reuse-venv=yes."""
+    if value:
+        args.reuse_venv = "yes"
+    return value
+
+
 def _posargs_finalizer(
     value: Sequence[Any], args: argparse.Namespace
 ) -> Sequence[Any] | list[Any]:
@@ -380,6 +419,7 @@ options.add_options(
         "--default-venv-backend",
         group=options.groups["environment"],
         noxfile=True,
+        default=lambda: os.environ.get("NOX_DEFAULT_VENV_BACKEND"),
         merge_func=_default_venv_backend_merge_func,
         help=(
             "Virtual environment backend to use by default for Nox sessions, this is"
@@ -414,6 +454,18 @@ options.add_options(
             " creating a venv. This is an alias for '--force-venv-backend none'."
         ),
     ),
+    _option_set.Option(
+        "reuse_venv",
+        "--reuse-venv",
+        group=options.groups["environment"],
+        noxfile=True,
+        merge_func=_reuse_venv_merge_func,
+        help=(
+            "Controls existing virtualenvs recreation. This is ``'no'`` by"
+            " default, but any of ``('yes', 'no', 'always', 'never')`` are accepted."
+        ),
+        choices=["yes", "no", "always", "never"],
+    ),
     *_option_set.make_flag_pair(
         "reuse_existing_virtualenvs",
         ("-r", "--reuse-existing-virtualenvs"),
@@ -422,7 +474,8 @@ options.add_options(
             "--no-reuse-existing-virtualenvs",
         ),
         group=options.groups["environment"],
-        help="Reuse existing virtualenvs instead of recreating them.",
+        help="This is an alias for '--reuse-venv=yes|no'.",
+        finalizer_func=_reuse_existing_virtualenvs_finalizer,
     ),
     _option_set.Option(
         "R",
diff --git a/nox/_parametrize.py b/nox/_parametrize.py
index ad83b53..865cb19 100644
--- a/nox/_parametrize.py
+++ b/nox/_parametrize.py
@@ -168,7 +168,7 @@ def update_param_specs(
     combined_specs = []
     for new_spec in new_specs:
         for spec in param_specs:
-            spec = spec.copy()
-            spec.update(new_spec)
-            combined_specs.append(spec)
+            spec_copy = spec.copy()
+            spec_copy.update(new_spec)
+            combined_specs.append(spec_copy)
     return combined_specs
diff --git a/nox/_version.py b/nox/_version.py
index 261a298..e8c3b0e 100644
--- a/nox/_version.py
+++ b/nox/_version.py
@@ -22,7 +22,7 @@ from packaging.specifiers import InvalidSpecifier, SpecifierSet
 from packaging.version import InvalidVersion, Version
 
 if sys.version_info >= (3, 8):
-    import importlib.metadata as metadata
+    from importlib import metadata
 else:
     import importlib_metadata as metadata
 
diff --git a/nox/logger.py b/nox/logger.py
index f98d803..333d8bd 100644
--- a/nox/logger.py
+++ b/nox/logger.py
@@ -27,13 +27,12 @@ def _get_format(colorlog: bool, add_timestamp: bool) -> str:
     if colorlog:
         if add_timestamp:
             return "%(cyan)s%(name)s > [%(asctime)s] %(log_color)s%(message)s"
-        else:
-            return "%(cyan)s%(name)s > %(log_color)s%(message)s"
-    else:
-        if add_timestamp:
-            return "%(name)s > [%(asctime)s] %(message)s"
-        else:
-            return "%(name)s > %(message)s"
+        return "%(cyan)s%(name)s > %(log_color)s%(message)s"
+
+    if add_timestamp:
+        return "%(name)s > [%(asctime)s] %(message)s"
+
+    return "%(name)s > %(message)s"
 
 
 class NoxFormatter(logging.Formatter):
diff --git a/nox/sessions.py b/nox/sessions.py
index f29e6bf..a98916f 100644
--- a/nox/sessions.py
+++ b/nox/sessions.py
@@ -562,7 +562,7 @@ class Session:
             raise ValueError("At least one argument required to install().")
 
         if self._runner.global_config.no_install and venv._reused:
-            return None
+            return
 
         # Escape args that should be (conda-specific; pip install does not need this)
         args = _dblquote_pkg_install_args(args)
@@ -645,7 +645,7 @@ class Session:
             raise ValueError("At least one argument required to install().")
 
         if self._runner.global_config.no_install and venv._reused:
-            return None
+            return
 
         if "silent" not in kwargs:
             kwargs["silent"] = True
@@ -767,9 +767,7 @@ class SessionRunner:
             self.venv = PassthroughEnv()
             return
 
-        reuse_existing = (
-            self.func.reuse_venv or self.global_config.reuse_existing_virtualenvs
-        )
+        reuse_existing = self.reuse_existing_venv()
 
         if backend is None or backend in {"virtualenv", "venv", "uv"}:
             self.venv = VirtualEnv(
@@ -795,6 +793,51 @@ class SessionRunner:
 
         self.venv.create()
 
+    def reuse_existing_venv(self) -> bool:
+        """
+        Determines whether to reuse an existing virtual environment.
+
+        The decision matrix is as follows:
+
+        +--------------------------+-----------------+-------------+
+        | global_config.reuse_venv | func.reuse_venv | Reuse venv? |
+        +==========================+=================+=============+
+        | "always"                 | N/A             | Yes         |
+        +--------------------------+-----------------+-------------+
+        | "never"                  | N/A             | No          |
+        +--------------------------+-----------------+-------------+
+        | "yes"                    | True|None       | Yes         |
+        +--------------------------+-----------------+-------------+
+        | "yes"                    | False           | No          |
+        +--------------------------+-----------------+-------------+
+        | "no"                     | True            | Yes         |
+        +--------------------------+-----------------+-------------+
+        | "no"                     | False|None      | No          |
+        +--------------------------+-----------------+-------------+
+
+        Summary
+        ~~~~~~~
+        - "always" forces reuse regardless of `func.reuse_venv`.
+        - "never" forces recreation regardless of `func.reuse_venv`.
+        - "yes" and "no" respect `func.reuse_venv` being ``False`` or ``True`` respectively.
+
+        Returns:
+            bool: True if the existing virtual environment should be reused, False otherwise.
+        """
+
+        return any(
+            (
+                # "always" forces reuse regardless of func.reuse_venv
+                self.global_config.reuse_venv == "always",
+                # Respect func.reuse_venv when it's explicitly True, unless global_config is "never"
+                self.func.reuse_venv is True
+                and self.global_config.reuse_venv != "never",
+                # Delegate to reuse ("yes") when func.reuse_venv is not explicitly False
+                self.func.reuse_venv is not False
+                and self.global_config.reuse_venv == "yes",
+            )
+        )
+
     def execute(self) -> Result:
         logger.warning(f"Running session {self.friendly_name}")
 
diff --git a/nox/tox_to_nox.py b/nox/tox_to_nox.py
index 37e184f..36df7ed 100644
--- a/nox/tox_to_nox.py
+++ b/nox/tox_to_nox.py
@@ -20,6 +20,7 @@ import argparse
 import os
 import pkgutil
 import re
+import sys
 from configparser import ConfigParser
 from pathlib import Path
 from subprocess import check_output
@@ -27,9 +28,15 @@ from typing import Any, Iterable
 
 import jinja2
 import tox.config
-from tox import __version__ as TOX_VERSION
 
-TOX4 = TOX_VERSION[0] == "4"
+if sys.version_info < (3, 8):
+    import importlib_metadata as metadata
+else:
+    from importlib import metadata
+
+TOX_VERSION = metadata.version("tox")
+
+TOX4 = int(TOX_VERSION.split(".")[0]) >= 4
 
 if TOX4:
     _TEMPLATE = jinja2.Template(
diff --git a/pyproject.toml b/pyproject.toml
index f3dc726..389e320 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -74,20 +74,21 @@ extend-select = [
   "C4",          # flake8-comprehensions
   "ICN",         # flake8-import-conventions
   "ISC",         # flake8-implicit-str-concat
+  "PL",          # pylint
   "PGH",         # pygrep-hooks
   "PIE",         # flake8-pie
   "RUF",         # Ruff-specific
   "SIM",         # flake8-simplify
   "UP",          # pyupgrade
   "YTT",         # flake8-2020
+  "EXE",         # flake8-executable
 ]
 ignore = [
-  "ISC001", # Conflicts with formatter
+  "ISC001",  # Conflicts with formatter
+  "PLR09",   # Too many X
+  "PLR2004", # Magic value used in comparison
 ]
 
-[tool.ruff]
-target-version = "py37"
-
 [tool.isort]
 profile = "black"
 


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 5266296..160beae 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,3 +1,23 @@
+# Copyright 2023 Alethea Katherine Flowers
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+from __future__ import annotations
+
+import re
+from pathlib import Path
+from string import Template
+from typing import Callable
+
 import pytest
 
 
@@ -6,3 +26,29 @@ def reset_color_envvars(monkeypatch):
     """Remove color-related envvars to fix test output"""
     monkeypatch.delenv("FORCE_COLOR", raising=False)
     monkeypatch.delenv("NO_COLOR", raising=False)
+
+
+RESOURCES = Path(__file__).parent.joinpath("resources")
+
+
+@pytest.fixture
+def generate_noxfile_options(tmp_path: Path) -> Callable[..., str]:
+    """Generate noxfile.py with test and templated options.
+
+    The options are enabled (if disabled) and the values are applied
+    if a matching format string is encountered with the option name.
+    """
+
+    def generate_noxfile(**option_mapping: str | bool) -> str:
+        path = Path(RESOURCES) / "noxfile_options.py"
+        text = path.read_text(encoding="utf8")
+        if option_mapping:
+            for opt, _val in option_mapping.items():
+                # "uncomment" options with values provided
+                text = re.sub(rf"(# )?nox.options.{opt}", f"nox.options.{opt}", text)
+            text = Template(text).safe_substitute(**option_mapping)
+        path = tmp_path / "noxfile.py"
+        path.write_text(text)
+        return str(path)
+
+    return generate_noxfile
diff --git a/tests/resources/noxfile_options.py b/tests/resources/noxfile_options.py
index b625cae..6bd4e88 100644
--- a/tests/resources/noxfile_options.py
+++ b/tests/resources/noxfile_options.py
@@ -16,8 +16,9 @@ from __future__ import annotations
 
 import nox
 
-nox.options.reuse_existing_virtualenvs = True
-# nox.options.error_on_missing_interpreters = {error_on_missing_interpreters} # used by tests
+# nox.options.reuse_existing_virtualenvs = ${reuse_existing_virtualenvs}
+# nox.options.reuse_venv = "${reuse_venv}"
+# nox.options.error_on_missing_interpreters = ${error_on_missing_interpreters}
 nox.options.sessions = ["test"]
 
 
diff --git a/tests/test_main.py b/tests/test_main.py
index e33277b..4034f68 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -28,9 +28,9 @@ import nox._options
 import nox.registry
 import nox.sessions
 
-try:
-    import importlib.metadata as metadata
-except ImportError:
+if sys.version_info >= (3, 8):
+    from importlib import metadata
+else:
     import importlib_metadata as metadata
 
 
@@ -61,6 +61,7 @@ def test_main_no_args(monkeypatch):
         assert config.sessions is None
         assert not config.no_venv
         assert not config.reuse_existing_virtualenvs
+        assert not config.reuse_venv
         assert not config.stop_on_first_error
         assert config.posargs == []
 
@@ -101,6 +102,7 @@ def test_main_long_form_args():
         assert config.force_venv_backend == "none"
         assert config.no_venv is True
         assert config.reuse_existing_virtualenvs is True
+        assert config.reuse_venv == "yes"
         assert config.stop_on_first_error is True
         assert config.posargs == []
 
@@ -180,6 +182,7 @@ def test_main_short_form_args(monkeypatch):
         assert config.default_venv_backend == "venv"
         assert config.force_venv_backend == "conda"
         assert config.reuse_existing_virtualenvs is True
+        assert config.reuse_venv == "yes"
 
 
 def test_main_explicit_sessions(monkeypatch):
@@ -256,8 +259,33 @@ def test_main_list_option_from_nox_env_var(monkeypatch, var, option, env, values
         config = execute.call_args[1]["global_config"]
         config_values = getattr(config, option)
         assert len(config_values) == len(values)
-        for value in values:
-            assert value in config_values
+        assert all(value in config_values for value in values)
+
+
+@pytest.mark.parametrize(
+    "options,env,expected",
+    [
+        (["--default-venv-backend", "conda"], "", "conda"),
+        ([], "mamba", "mamba"),
+        (["--default-venv-backend", "conda"], "mamba", "conda"),
+    ],
+    ids=["option", "env", "option_over_env"],
+)
+def test_default_venv_backend_option(monkeypatch, options, env, expected):
+    monkeypatch.setenv("NOX_DEFAULT_VENV_BACKEND", env)
+    monkeypatch.setattr(sys, "argv", [sys.executable, *options])
+    with mock.patch("nox.workflow.execute") as execute:
+        execute.return_value = 0
+
+        # Call the main function.
+        with mock.patch.object(sys, "exit") as exit:
+            nox.__main__.main()
+            exit.assert_called_once_with(0)
+        assert execute.called
+
+        # Verify that the default venv backend is set in the config.
+        config = execute.call_args[1]["global_config"]
+        assert config.default_venv_backend == expected
 
 
 def test_main_positional_args(capsys, monkeypatch):
@@ -466,7 +494,8 @@ def test_main_with_bad_session_names(run_nox, session):
     assert session in stderr
 
 
-def test_main_noxfile_options(monkeypatch):
+def test_main_noxfile_options(monkeypatch, generate_noxfile_options):
+    noxfile_path = generate_noxfile_options(reuse_existing_virtualenvs=True)
     monkeypatch.setattr(
         sys,
         "argv",
@@ -476,7 +505,7 @@ def test_main_noxfile_options(monkeypatch):
             "-s",
             "test",
             "--noxfile",
-            os.path.join(RESOURCES, "noxfile_options.py"),
+            noxfile_path,
         ],
     )
 
@@ -491,9 +520,11 @@ def test_main_noxfile_options(monkeypatch):
         # Verify that the config looks correct.
         config = honor_list_request.call_args[1]["global_config"]
         assert config.reuse_existing_virtualenvs is True
+        assert config.reuse_venv == "yes"
 
 
-def test_main_noxfile_options_disabled_by_flag(monkeypatch):
+def test_main_noxfile_options_disabled_by_flag(monkeypatch, generate_noxfile_options):
+    noxfile_path = generate_noxfile_options(reuse_existing_virtualenvs=True)
     monkeypatch.setattr(
         sys,
         "argv",
@@ -504,7 +535,7 @@ def test_main_noxfile_options_disabled_by_flag(monkeypatch):
             "test",
             "--no-reuse-existing-virtualenvs",
             "--noxfile",
-            os.path.join(RESOURCES, "noxfile_options.py"),
+            noxfile_path,
         ],
     )
 
@@ -519,13 +550,15 @@ def test_main_noxfile_options_disabled_by_flag(monkeypatch):
         # Verify that the config looks correct.
         config = honor_list_request.call_args[1]["global_config"]
         assert config.reuse_existing_virtualenvs is False
+        assert config.reuse_venv == "no"
 
 
-def test_main_noxfile_options_sessions(monkeypatch):
+def test_main_noxfile_options_sessions(monkeypatch, generate_noxfile_options):
+    noxfile_path = generate_noxfile_options(reuse_existing_virtualenvs=True)
     monkeypatch.setattr(
         sys,
         "argv",
-        ["nox", "-l", "--noxfile", os.path.join(RESOURCES, "noxfile_options.py")],
+        ["nox", "-l", "--noxfile", noxfile_path],
     )
 
     with mock.patch("nox.tasks.honor_list_request") as honor_list_request:
@@ -693,7 +726,11 @@ def test_main_reuse_existing_virtualenvs_no_install(monkeypatch):
         with mock.patch.object(sys, "exit"):
             nox.__main__.main()
         config = execute.call_args[1]["global_config"]
-    assert config.reuse_existing_virtualenvs and config.no_install
+    assert (
+        config.reuse_existing_virtualenvs
+        and config.no_install
+        and config.reuse_venv == "yes"
+    )
 
 
 @pytest.mark.parametrize(
@@ -709,7 +746,7 @@ def test_main_reuse_existing_virtualenvs_no_install(monkeypatch):
 )
 def test_main_noxfile_options_with_ci_override(
     monkeypatch,
-    tmp_path,
+    generate_noxfile_options,
     should_set_ci_env_var,
     noxfile_option_value,
     expected_final_value,
@@ -723,16 +760,12 @@ def test_main_noxfile_options_with_ci_override(
     )
     monkeypatch.setattr(nox, "options", nox._options.noxfile_options)
 
-    noxfile_path = Path(RESOURCES) / "noxfile_options.py"
-    if noxfile_option_value is not None:
-        # Temp noxfile with error_on_missing_interpreters set
-        noxfile_text = noxfile_path.read_text()
-        noxfile_text = noxfile_text.replace("# nox", "nox")
-        noxfile_text = noxfile_text.format(
+    if noxfile_option_value is None:
+        noxfile_path = generate_noxfile_options()
+    else:
+        noxfile_path = generate_noxfile_options(
             error_on_missing_interpreters=noxfile_option_value
         )
-        noxfile_path = tmp_path / "noxfile.py"
-        noxfile_path.write_text(noxfile_text)
 
     monkeypatch.setattr(
         sys,
@@ -747,3 +780,88 @@ def test_main_noxfile_options_with_ci_override(
             nox.__main__.main()
         config = honor_list_request.call_args[1]["global_config"]
     assert config.error_on_missing_interpreters == expected_final_value
+
+
+@pytest.mark.parametrize(
+    "reuse_venv",
+    [
+        "yes",
+        "no",
+        "always",
+        "never",
+    ],
+)
+def test_main_reuse_venv_cli_flags(monkeypatch, generate_noxfile_options, reuse_venv):
+    monkeypatch.setattr(sys, "argv", ["nox", "--reuse-venv", reuse_venv])
+    with mock.patch("nox.workflow.execute", return_value=0) as execute:
+        with mock.patch.object(sys, "exit"):
+            nox.__main__.main()
+        config = execute.call_args[1]["global_config"]
+    assert (
+        not config.reuse_existing_virtualenvs
+    )  # should remain unaffected in this case
+    assert config.reuse_venv == reuse_venv
+
+
+@pytest.mark.parametrize(
+    ("reuse_venv", "reuse_existing_virtualenvs", "expected"),
+    [
+        ("yes", None, "yes"),
+        ("yes", False, "yes"),
+        ("yes", True, "yes"),
+        ("yes", "--no-reuse-existing-virtualenvs", "no"),
+        ("yes", "--reuse-existing-virtualenvs", "yes"),
+        ("no", None, "no"),
+        ("no", False, "no"),
+        ("no", True, "yes"),
+        ("no", "--no-reuse-existing-virtualenvs", "no"),
+        ("no", "--reuse-existing-virtualenvs", "yes"),
+        ("always", None, "always"),
+        ("always", False, "always"),
+        ("always", True, "yes"),
+        ("always", "--no-reuse-existing-virtualenvs", "no"),
+        ("always", "--reuse-existing-virtualenvs", "yes"),
+        ("never", None, "never"),
+        ("never", False, "never"),
+        ("never", True, "yes"),
+        ("never", "--no-reuse-existing-virtualenvs", "no"),
+        ("never", "--reuse-existing-virtualenvs", "yes"),
+    ],
+)
+def test_main_noxfile_options_reuse_venv_compat_check(
+    monkeypatch,
+    generate_noxfile_options,
+    reuse_venv,
+    reuse_existing_virtualenvs,
+    expected,
+):
+    cmd_args = ["nox", "-l"]
+    # CLI Compat Check
+    if isinstance(reuse_existing_virtualenvs, str):
+        cmd_args += [reuse_existing_virtualenvs]
+
+    # Generate noxfile
+    if isinstance(reuse_existing_virtualenvs, bool):
+        # Non-CLI Compat Check
+        noxfile_path = generate_noxfile_options(
+            reuse_venv=reuse_venv, reuse_existing_virtualenvs=reuse_existing_virtualenvs
+        )
+    else:
+        noxfile_path = generate_noxfile_options(reuse_venv=reuse_venv)
+    cmd_args += ["--noxfile", str(noxfile_path)]
+
+    # Reset nox.options
+    monkeypatch.setattr(
+        nox._options, "noxfile_options", nox._options.options.noxfile_namespace()
+    )
+    monkeypatch.setattr(nox, "options", nox._options.noxfile_options)
+
+    # Execute
+    monkeypatch.setattr(sys, "argv", cmd_args)
+    with mock.patch(
+        "nox.tasks.honor_list_request", return_value=0
+    ) as honor_list_request:
+        with mock.patch("sys.exit"):
+            nox.__main__.main()
+        config = honor_list_request.call_args[1]["global_config"]
+    assert config.reuse_venv == expected
diff --git a/tests/test_sessions.py b/tests/test_sessions.py
index 4b83146..24ebd63 100644
--- a/tests/test_sessions.py
+++ b/tests/test_sessions.py
@@ -857,7 +857,7 @@ class TestSessionRunner:
                 noxfile=os.path.join(os.getcwd(), "noxfile.py"),
                 envdir="envdir",
                 posargs=[],
-                reuse_existing_virtualenvs=False,
+                reuse_venv="no",
                 error_on_missing_interpreters="CI" in os.environ,
             ),
             manifest=mock.create_autospec(nox.manifest.Manifest),
@@ -960,10 +960,32 @@ class TestSessionRunner:
     def test__create_venv_unexpected_venv_backend(self):
         runner = self.make_runner()
         runner.func.venv_backend = "somenewenvtool"
-
         with pytest.raises(ValueError, match="venv_backend"):
             runner._create_venv()
 
+    @pytest.mark.parametrize(
+        ("reuse_venv", "reuse_venv_func", "should_reuse"),
+        [
+            ("yes", None, True),
+            ("yes", False, False),
+            ("yes", True, True),
+            ("no", None, False),
+            ("no", False, False),
+            ("no", True, True),
+            ("always", None, True),
+            ("always", False, True),
+            ("always", True, True),
+            ("never", None, False),
+            ("never", False, False),
+            ("never", True, False),
+        ],
+    )
+    def test__reuse_venv_outcome(self, reuse_venv, reuse_venv_func, should_reuse):
+        runner = self.make_runner()
+        runner.func.reuse_venv = reuse_venv_func
+        runner.global_config.reuse_venv = reuse_venv
+        assert runner.reuse_existing_venv() == should_reuse
+
     def make_runner_with_mock_venv(self):
         runner = self.make_runner()
         runner._create_venv = mock.Mock()
diff --git a/tests/test_tasks.py b/tests/test_tasks.py
index fe3e0b5..947239e 100644
--- a/tests/test_tasks.py
+++ b/tests/test_tasks.py
@@ -320,7 +320,7 @@ def test_filter_manifest_tags_not_found(tags, caplog):
     assert "Tag selection caused no sessions to be selected." in caplog.text
 
 
-def test_merge_sessions_and_tags(reset_global_nox_options):
+def test_merge_sessions_and_tags(reset_global_nox_options, generate_noxfile_options):
     @nox.session(tags=["foobar"])
     def test():
         pass
@@ -329,8 +329,9 @@ def test_merge_sessions_and_tags(reset_global_nox_options):
     def bar():
         pass
 
+    noxfile_path = generate_noxfile_options(reuse_existing_virtualenvs=True)
     config = _options.options.namespace(
-        noxfile=os.path.join(RESOURCES, "noxfile_options.py"),
+        noxfile=noxfile_path,
         sessions=None,
         pythons=(),
         posargs=[],
diff --git a/tests/test_tox_to_nox.py b/tests/test_tox_to_nox.py
index 11815b7..50ab4f2 100644
--- a/tests/test_tox_to_nox.py
+++ b/tests/test_tox_to_nox.py
@@ -18,11 +18,10 @@ import sys
 import textwrap
 
 import pytest
-from tox import __version__ as TOX_VERSION
 
 tox_to_nox = pytest.importorskip("nox.tox_to_nox")
 
-TOX4 = TOX_VERSION[0] == "4"
+TOX4 = tox_to_nox.TOX4
 PYTHON_VERSION = f"{sys.version_info.major}.{sys.version_info.minor}"
 PYTHON_VERSION_NODOT = PYTHON_VERSION.replace(".", "")
 
diff --git a/tests/test_virtualenv.py b/tests/test_virtualenv.py
index b5cf49a..d3973b2 100644
--- a/tests/test_virtualenv.py
+++ b/tests/test_virtualenv.py
@@ -24,16 +24,20 @@ from typing import NamedTuple
 from unittest import mock
 
 import pytest
-import virtualenv
 from packaging import version
 
 import nox.virtualenv
 
+if sys.version_info < (3, 8):
+    import importlib_metadata as metadata
+else:
+    from importlib import metadata
+
 IS_WINDOWS = nox.virtualenv._SYSTEM == "Windows"
 HAS_CONDA = shutil.which("conda") is not None
 HAS_UV = shutil.which("uv") is not None
 RAISE_ERROR = "RAISE_ERROR"
-VIRTUALENV_VERSION = virtualenv.__version__
+VIRTUALENV_VERSION = metadata.version("virtualenv")
 
 
 class TextProcessResult(NamedTuple):