Repository: wntrblm/nox
Pull request #707 (2023-04-23 09:31:03)

feat: more option argument completions
See individual commits for details.

scop:  Thanks for the review, @stasfilin, comments addressed. A few mypy issues remain, will need to look into them:
 ```
 nox/_options.py:25: error: Cannot find implementation or library stub for module named "argcomplete.completers"  [import]
nox/_options.py:25: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports
nox/_options.py:246: error: Generator has incompatible item type "Union[str, Sequence[str], bool, None]"; expected "Optional[str]"  [misc]
```
 

Patch:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 10cbe2a..5ba9d16 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -5,3 +5,7 @@ updates:
     directory: "/"
     schedule:
       interval: weekly
+    groups:
+      actions:
+        patterns:
+          - "*"
diff --git a/.github/workflows/action.yml b/.github/workflows/action.yml
index a88812f..65b15a6 100644
--- a/.github/workflows/action.yml
+++ b/.github/workflows/action.yml
@@ -9,6 +9,8 @@ on:
       - ".github/workflows/action.yml"
       - ".github/action_helper.py"
       - "action.yml"
+  workflow_dispatch:
+    # allow manual runs on branches without a PR
 env:
   FORCE_COLOR: "1"
 jobs:
@@ -27,17 +29,17 @@ jobs:
             macos-13,
           ]
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
       - uses: ./
       - run: nox --non-interactive --error-on-missing-interpreter --session github_actions_default_tests
   action-all-tests:
     runs-on: windows-latest
     steps:
-      - uses: actions/checkout@v3
-      - uses: actions/setup-python@v2
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v5
         with:
           python-version: 3.9
       - uses: ./
         with:
-          python-versions: "3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, pypy-2.7, pypy-3.7, pypy-3.8, pypy-3.9-v7.3.9"
+          python-versions: "3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, pypy-2.7, pypy-3.7, pypy-3.8, pypy-3.9, pypy-3.10"
       - run: nox --non-interactive --error-on-missing-interpreter --session github_actions_all_tests
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9d2476e..86a17a3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -3,6 +3,8 @@ on:
   push:
     branches: [main]
   pull_request:
+  workflow_dispatch:
+    # allow manual runs on branches without a PR
 
 env:
   FORCE_COLOR: "1"
@@ -17,15 +19,15 @@ jobs:
     strategy:
       matrix:
         os: [ubuntu-20.04, windows-latest, macos-latest]
-        python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
+        python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
       - name: Set up Python ${{ matrix.python-version }}
-        uses: actions/setup-python@v4
+        uses: actions/setup-python@v5
         with:
           python-version: ${{ matrix.python-version }}
       - name: Set up Miniconda
-        uses: conda-incubator/setup-miniconda@v2
+        uses: conda-incubator/setup-miniconda@v3
         with:
           auto-update-conda: true
           python-version: ${{ matrix.python-version }}
@@ -40,9 +42,9 @@ jobs:
   lint:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
       - name: Set up Python 3.9
-        uses: actions/setup-python@v4
+        uses: actions/setup-python@v5
         with:
           python-version: 3.9
       - name: Install Nox-under-test
@@ -53,9 +55,9 @@ jobs:
   docs:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
       - name: Set up Python 3.9
-        uses: actions/setup-python@v4
+        uses: actions/setup-python@v5
         with:
           python-version: 3.9
       - name: Install Nox-under-test
@@ -68,7 +70,7 @@ jobs:
     runs-on: ubuntu-latest
     if: github.event_name == 'push' && contains(github.ref, 'refs/tags/')
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
       - name: Build sdist and wheel
         run: pipx run build
       - name: Publish distribution PyPI
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 37bb980..3f4808f 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -4,15 +4,8 @@ ci:
   autofix_commit_msg: "style: pre-commit fixes"
 
 repos:
-  - repo: https://github.com/psf/black
-    rev: "23.1.0"
-    hooks:
-      - id: black
-        args:
-          - "--preview"
-
   - repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v4.4.0
+    rev: v4.5.0
     hooks:
       - id: check-added-large-files
       - id: check-case-conflict
@@ -26,18 +19,19 @@ repos:
       - id: trailing-whitespace
 
   - repo: https://github.com/tox-dev/pyproject-fmt
-    rev: "0.7.0"
+    rev: "1.3.0"
     hooks:
       - id: pyproject-fmt
 
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.0.255
+    rev: v0.1.3
     hooks:
       - id: ruff
         args: ["--fix", "--show-fixes"]
+      - id: ruff-format
 
   - repo: https://github.com/pre-commit/mirrors-mypy
-    rev: v1.0.1
+    rev: v1.6.1
     hooks:
       - id: mypy
         files: ^nox/
@@ -48,7 +42,7 @@ repos:
           - importlib_metadata
 
   - repo: https://github.com/codespell-project/codespell
-    rev: v2.2.2
+    rev: v2.2.6
     hooks:
       - id: codespell
 
diff --git a/action.yml b/action.yml
index cfac6c9..f0392ba 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.7, 3.8, 3.9, 3.10, 3.11, pypy-3.7, pypy-3.8, pypy-3.9"
+    default: "3.8, 3.9, 3.10, 3.11, 3.12, pypy-3.9, pypy-3.10"
 branding:
   icon: package
   color: blue
@@ -12,10 +12,10 @@ branding:
 runs:
   using: composite
   steps:
-    - uses: actions/setup-python@v4
+    - uses: actions/setup-python@v5
       id: localpython
       with:
-        python-version: "3.7 - 3.11"
+        python-version: "3.7 - 3.12"
         update-environment: false
 
     - name: "Validate input"
@@ -24,7 +24,7 @@ runs:
         '${{ steps.localpython.outputs.python-path }}' '${{ github.action_path }}/.github/action_helper.py' '${{ inputs.python-versions }}' >>${GITHUB_OUTPUT}
       shell: bash
 
-    - uses: actions/setup-python@v4
+    - uses: actions/setup-python@v5
       id: allpython
       with:
         python-version: "${{ join(fromJSON(steps.helper.outputs.interpreters), '\n') }}"
diff --git a/docs/tutorial.rst b/docs/tutorial.rst
index ac76a54..4b3131f 100644
--- a/docs/tutorial.rst
+++ b/docs/tutorial.rst
@@ -143,20 +143,20 @@ If your project is a Python package and you want to install it:
 
 In some cases such as Python binary extensions, your package may depend on
 code compiled outside of the Python ecosystem. To make sure a low-level
-dependency (e.g. ``libfoo``) is available during installation
+dependency (e.g. ``libfoo``) is available during installation:
 
 .. code-block:: python
 
     @nox.session
     def tests(session):
         ...
-        session.run_always(
+        session.run_install(
             "cmake", "-DCMAKE_BUILD_TYPE=Debug",
             "-S", libfoo_src_dir,
             "-B", build_dir,
             external=True,
         )
-        session.run_always(
+        session.run_install(
             "cmake",
             "--build", build_dir,
             "--config", "Debug",
@@ -166,6 +166,9 @@ dependency (e.g. ``libfoo``) is available during installation
         session.install(".")
         ...
 
+These commands will run even if you are only installing, and will not run if
+the environment is being reused without reinstallation.
+
 Running commands
 ----------------
 
diff --git a/docs/usage.rst b/docs/usage.rst
index 9dbf72e..bc23761 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -165,7 +165,7 @@ Finally note that the ``--no-venv`` flag is a shortcut for ``--force-venv-backen
 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 re-use 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``:
 
 .. code-block:: console
 
@@ -186,7 +186,7 @@ The ``--no-install`` option causes the following session methods to return early
 
 - :func:`session.install <nox.sessions.Session.install>`
 - :func:`session.conda_install <nox.sessions.Session.conda_install>`
-- :func:`session.run_always <nox.sessions.Session.run_always>`
+- :func:`session.run_install <nox.sessions.Session.run_install>`
 
 This option has no effect if the virtualenv is not being reused.
 
diff --git a/nox/__main__.py b/nox/__main__.py
index f8dcbfd..96f5128 100644
--- a/nox/__main__.py
+++ b/nox/__main__.py
@@ -22,12 +22,34 @@ control to :meth:``nox.workflow.execute``.
 from __future__ import annotations
 
 import sys
+from typing import Any
 
 from nox import _options, tasks, workflow
 from nox._version import get_nox_version
 from nox.logger import setup_logging
 
 
+def execute_workflow(args: Any) -> int:
+    """
+    Execute the appropriate tasks.
+    """
+
+    return workflow.execute(
+        global_config=args,
+        workflow=(
+            tasks.load_nox_module,
+            tasks.merge_noxfile_options,
+            tasks.discover_manifest,
+            tasks.filter_manifest,
+            tasks.honor_list_request,
+            tasks.run_manifest,
+            tasks.print_summary,
+            tasks.create_report,
+            tasks.final_reduce,
+        ),
+    )
+
+
 def main() -> None:
     args = _options.options.parse_args()
 
@@ -43,21 +65,7 @@ def main() -> None:
         color=args.color, verbose=args.verbose, add_timestamp=args.add_timestamp
     )
 
-    # Execute the appropriate tasks.
-    exit_code = workflow.execute(
-        global_config=args,
-        workflow=(
-            tasks.load_nox_module,
-            tasks.merge_noxfile_options,
-            tasks.discover_manifest,
-            tasks.filter_manifest,
-            tasks.honor_list_request,
-            tasks.run_manifest,
-            tasks.print_summary,
-            tasks.create_report,
-            tasks.final_reduce,
-        ),
-    )
+    exit_code = execute_workflow(args)
 
     # Done; exit.
     sys.exit(exit_code)
diff --git a/nox/_decorators.py b/nox/_decorators.py
index 648d96a..aaef96a 100644
--- a/nox/_decorators.py
+++ b/nox/_decorators.py
@@ -26,19 +26,23 @@ from . import _typing
 if TYPE_CHECKING:
     from ._parametrize import Param
 
+T = TypeVar("T", bound=Callable[..., Any])
+
 
 class FunctionDecorator:
+    """This is a function decorator."""
+
     def __new__(
-        cls, func: Callable[..., Any], *args: Any, **kwargs: Any
+        cls: Any, func: Callable[..., Any], *args: Any, **kwargs: Any
     ) -> FunctionDecorator:
         obj = super().__new__(cls)
-        return functools.wraps(func)(obj)
-
-
-T = TypeVar("T", bound=Callable[..., Any])
+        functools.update_wrapper(obj, func)
+        return cast(FunctionDecorator, obj)
 
 
 def _copy_func(src: T, name: str | None = None) -> T:
+    """This function copies another function, optionally with a new name."""
+
     dst = types.FunctionType(
         src.__code__,
         src.__globals__,
@@ -53,6 +57,8 @@ def _copy_func(src: T, name: str | None = None) -> T:
 
 
 class Func(FunctionDecorator):
+    """This is a function decorator that adds additional Nox-specific metadata."""
+
     def __init__(
         self,
         func: Callable[..., Any],
@@ -63,7 +69,7 @@ class Func(FunctionDecorator):
         venv_params: Any = None,
         should_warn: Mapping[str, Any] | None = None,
         tags: Sequence[str] | None = None,
-    ):
+    ) -> None:
         self.func = func
         self.python = python
         self.reuse_venv = reuse_venv
@@ -77,6 +83,8 @@ class Func(FunctionDecorator):
         return self.func(*args, **kwargs)
 
     def copy(self, name: str | None = None) -> Func:
+        """Copy this function with a new name."""
+
         return Func(
             _copy_func(self.func, name),
             self.python,
@@ -90,6 +98,8 @@ class Func(FunctionDecorator):
 
 
 class Call(Func):
+    """This represents a call of a function with a particular set of arguments."""
+
     def __init__(self, func: Func, param_spec: Param) -> None:
         call_spec = param_spec.call_spec
         session_signature = f"({param_spec})"
@@ -124,5 +134,9 @@ class Call(Func):
         return super().__call__(*args, **kwargs)
 
     @classmethod
-    def generate_calls(cls, func: Func, param_specs: Iterable[Param]) -> list[Call]:
+    def generate_calls(
+        cls: type[Call], func: Func, param_specs: Iterable[Param]
+    ) -> list[Call]:
+        """Generates a list of calls based on the function and parameters."""
+
         return [cls(func, param_spec) for param_spec in param_specs]
diff --git a/nox/_option_set.py b/nox/_option_set.py
index 39a84f6..e85c574 100644
--- a/nox/_option_set.py
+++ b/nox/_option_set.py
@@ -23,7 +23,7 @@ import collections
 import functools
 from argparse import ArgumentError as ArgumentError
 from argparse import ArgumentParser, Namespace
-from collections.abc import Callable, Sequence
+from collections.abc import Callable, Iterable
 from typing import Any
 
 import argcomplete
@@ -86,13 +86,11 @@ class Option:
         noxfile: bool = False,
         merge_func: Callable[[Namespace, Namespace], Any] | None = None,
         finalizer_func: Callable[[Any, Namespace], Any] | None = None,
-        default: bool
-        | str
-        | None
-        | list[str]
-        | Callable[[], bool | str | None | list[str]] = None,
+        default: (
+            bool | str | None | list[str] | Callable[[], bool | str | None | list[str]]
+        ) = None,
         hidden: bool = False,
-        completer: Callable[..., Sequence[str]] | None = None,
+        completer: Callable[..., Iterable[str]] | None = None,
         **kwargs: Any,
     ) -> None:
         self.name = name
@@ -207,9 +205,9 @@ class OptionSet:
         self.parser_args = args
         self.parser_kwargs = kwargs
         self.options: collections.OrderedDict[str, Option] = collections.OrderedDict()
-        self.groups: collections.OrderedDict[str, OptionGroup] = (
-            collections.OrderedDict()
-        )
+        self.groups: collections.OrderedDict[
+            str, OptionGroup
+        ] = collections.OrderedDict()
 
     def add_options(self, *args: Option) -> None:
         """Adds a sequence of Options to the OptionSet.
diff --git a/nox/_options.py b/nox/_options.py
index 9c06f8a..39ebd4c 100644
--- a/nox/_options.py
+++ b/nox/_options.py
@@ -16,10 +16,14 @@ from __future__ import annotations
 
 import argparse
 import functools
+import itertools
 import os
 import sys
+from collections.abc import Iterable
 from typing import Any, Callable, Sequence
 
+import argcomplete
+
 from nox import _option_set
 from nox.tasks import discover_manifest, filter_manifest, load_nox_module
 
@@ -48,10 +52,8 @@ options.add_groups(
     _option_set.OptionGroup(
         "environment",
         "Environment options",
-        (
-            "These arguments are used to control Nox's creation and usage of virtual"
-            " environments."
-        ),
+        "These arguments are used to control Nox's creation and usage of virtual"
+        " environments.",
     ),
     _option_set.OptionGroup(
         "execution",
@@ -229,19 +231,42 @@ def _posargs_finalizer(
     return posargs[dash_index + 1 :]
 
 
+def _python_completer(
+    prefix: str, parsed_args: argparse.Namespace, **kwargs: Any
+) -> Iterable[str]:
+    module = load_nox_module(parsed_args)
+    manifest = discover_manifest(module, parsed_args)
+    return filter(
+        None,
+        (
+            session.func.python  # type:ignore[misc] # str sequences flattened, other non-strs falsey and filtered out
+            for session, _ in manifest.list_all_sessions()
+        ),
+    )
+
+
 def _session_completer(
     prefix: str, parsed_args: argparse.Namespace, **kwargs: Any
-) -> list[str]:
-    global_config = parsed_args
-    global_config.list_sessions = True
-    module = load_nox_module(global_config)
-    manifest = discover_manifest(module, global_config)
-    filtered_manifest = filter_manifest(manifest, global_config)
+) -> Iterable[str]:
+    parsed_args.list_sessions = True
+    module = load_nox_module(parsed_args)
+    manifest = discover_manifest(module, parsed_args)
+    filtered_manifest = filter_manifest(manifest, parsed_args)
     if isinstance(filtered_manifest, int):
         return []
-    return [
+    return (
         session.friendly_name for session, _ in filtered_manifest.list_all_sessions()
-    ]
+    )
+
+
+def _tag_completer(
+    prefix: str, parsed_args: argparse.Namespace, **kwargs: Any
+) -> Iterable[str]:
+    module = load_nox_module(parsed_args)
+    manifest = discover_manifest(module, parsed_args)
+    return itertools.chain.from_iterable(
+        filter(None, (session.tags for session, _ in manifest.list_all_sessions()))
+    )
 
 
 options.add_options(
@@ -300,6 +325,7 @@ options.add_options(
         nargs="*",
         default=default_env_var_list_factory("NOXPYTHON"),
         help="Only run sessions that use the given python interpreter versions.",
+        completer=_python_completer,
     ),
     _option_set.Option(
         "keywords",
@@ -309,6 +335,7 @@ options.add_options(
         noxfile=True,
         merge_func=functools.partial(_sessions_merge_func, "keywords"),
         help="Only run sessions that match the given expression.",
+        completer=argcomplete.completers.ChoicesCompleter(()),
     ),
     _option_set.Option(
         "tags",
@@ -319,6 +346,7 @@ options.add_options(
         merge_func=functools.partial(_sessions_merge_func, "tags"),
         nargs="*",
         help="Only run sessions with the given tags.",
+        completer=_tag_completer,
     ),
     _option_set.Option(
         "posargs",
@@ -394,7 +422,7 @@ options.add_options(
             "--no-reuse-existing-virtualenvs",
         ),
         group=options.groups["environment"],
-        help="Re-use existing virtualenvs instead of recreating them.",
+        help="Reuse existing virtualenvs instead of recreating them.",
     ),
     _option_set.Option(
         "R",
@@ -403,7 +431,7 @@ options.add_options(
         group=options.groups["environment"],
         action="store_true",
         help=(
-            "Re-use existing virtualenvs and skip package re-installation."
+            "Reuse existing virtualenvs and skip package re-installation."
             " This is an alias for '--reuse-existing-virtualenvs --no-install'."
         ),
         finalizer_func=_R_finalizer,
@@ -423,6 +451,7 @@ options.add_options(
         merge_func=_envdir_merge_func,
         group=options.groups["environment"],
         help="Directory where Nox will store virtualenvs, this is ``.nox`` by default.",
+        completer=argcomplete.completers.DirectoriesCompleter(),
     ),
     _option_set.Option(
         "extra_pythons",
@@ -432,9 +461,11 @@ options.add_options(
         nargs="*",
         default=default_env_var_list_factory("NOXEXTRAPYTHON"),
         help="Additionally, run sessions using the given python interpreter versions.",
+        completer=_python_completer,
     ),
     _option_set.Option(
         "force_pythons",
+        "-P",
         "--force-pythons",
         "--force-python",
         group=options.groups["python"],
@@ -443,8 +474,10 @@ options.add_options(
         help=(
             "Run sessions with the given interpreters instead of those listed in the"
             " Noxfile. This is a shorthand for ``--python=X.Y --extra-python=X.Y``."
+            " It will also work on sessions that don't have any interpreter parametrized."
         ),
         finalizer_func=_force_pythons_finalizer,
+        completer=_python_completer,
     ),
     *_option_set.make_flag_pair(
         "stop_on_first_error",
@@ -486,7 +519,7 @@ options.add_options(
         action="store_true",
         help=(
             "Skip invocations of session methods for installing packages"
-            " (session.install, session.conda_install, session.run_always)"
+            " (session.install, session.conda_install, session.run_install)"
             " when a virtualenv is being reused."
         ),
     ),
@@ -496,6 +529,7 @@ options.add_options(
         group=options.groups["reporting"],
         noxfile=True,
         help="Output a report of all sessions to the given filename.",
+        completer=argcomplete.completers.FilesCompleter(("json",)),
     ),
     _option_set.Option(
         "non_interactive",
diff --git a/nox/_parametrize.py b/nox/_parametrize.py
index 8b5d55c..ad83b53 100644
--- a/nox/_parametrize.py
+++ b/nox/_parametrize.py
@@ -78,7 +78,7 @@ class Param:
         elif isinstance(other, dict):
             return dict(zip(self.arg_names, self.args)) == other
 
-        raise NotImplementedError
+        return NotImplemented
 
 
 def _apply_param_specs(param_specs: Iterable[Param], f: Any) -> Any:
diff --git a/nox/command.py b/nox/command.py
index 9280f8e..3215772 100644
--- a/nox/command.py
+++ b/nox/command.py
@@ -29,6 +29,8 @@ if sys.version_info < (3, 8):  # pragma: no cover
 else:  # pragma: no cover
     from typing import Literal
 
+ExternalType = Literal["error", True, False]
+
 
 class CommandFailed(Exception):
     """Raised when an executed command returns a non-success status code."""
@@ -53,7 +55,7 @@ def which(program: str | os.PathLike[str], paths: Sequence[str] | None) -> str:
     raise CommandFailed(f"Program {program} not found")
 
 
-def _clean_env(env: Mapping[str, str] | None) -> dict[str, str] | None:
+def _clean_env(env: Mapping[str, str] | None = None) -> dict[str, str] | None:
     if env is None:
         return None
 
@@ -80,7 +82,7 @@ def run(
     paths: Sequence[str] | None = None,
     success_codes: Iterable[int] | None = None,
     log: bool = True,
-    external: Literal["error"] | bool = False,
+    external: ExternalType = False,
     **popen_kws: Any,
 ) -> str | bool:
     """Run a command-line program."""
diff --git a/nox/manifest.py b/nox/manifest.py
index 1eff952..fa9f797 100644
--- a/nox/manifest.py
+++ b/nox/manifest.py
@@ -230,6 +230,10 @@ class Manifest:
                 # Otherwise, add the extra specified python.
                 assert isinstance(func.python, str)
                 func.python = _unique_list(func.python, *extra_pythons)
+            elif not func.python and self._config.force_pythons:
+                # If a python is forced by the user, but the underlying function
+                # has no version parametrised, add it as sole occupant to func.python
+                func.python = _unique_list(*extra_pythons)
 
         # If the func has the python attribute set to a list, we'll need
         # to expand them.
diff --git a/nox/registry.py b/nox/registry.py
index 2f6e599..60765ae 100644
--- a/nox/registry.py
+++ b/nox/registry.py
@@ -36,12 +36,12 @@ def session_decorator(__func: F) -> F:
 @overload
 def session_decorator(
     __func: None = ...,
-    python: Python = ...,
-    py: Python = ...,
+    python: Python | None = ...,
+    py: Python | None = ...,
     reuse_venv: bool | None = ...,
     name: str | None = ...,
-    venv_backend: Any = ...,
-    venv_params: Any = ...,
+    venv_backend: Any | None = ...,
+    venv_params: Any | None = ...,
     tags: Sequence[str] | None = ...,
 ) -> Callable[[F], F]:
     ...
@@ -49,12 +49,12 @@ def session_decorator(
 
 def session_decorator(
     func: F | None = None,
-    python: Python = None,
-    py: Python = None,
+    python: Python | None = None,
+    py: Python | None = None,
     reuse_venv: bool | None = None,
     name: str | None = None,
-    venv_backend: Any = None,
-    venv_params: Any = None,
+    venv_backend: Any | None = None,
+    venv_params: Any | None = None,
     tags: Sequence[str] | None = None,
 ) -> F | Callable[[F], F]:
     """Designate the decorated function as a session."""
diff --git a/nox/sessions.py b/nox/sessions.py
index bab59d5..02e1e69 100644
--- a/nox/sessions.py
+++ b/nox/sessions.py
@@ -390,18 +390,20 @@ class Session:
             **kwargs,
         )
 
-    def run_always(
+    def run_install(
         self,
         *args: str | os.PathLike[str],
         env: Mapping[str, str] | None = None,
         include_outer_env: bool = True,
         **kwargs: Any,
     ) -> Any | None:
-        """Run a command **always**.
+        """Run a command in the install step.
 
         This is a variant of :meth:`run` that runs even in the presence of
         ``--install-only``. This method returns early if ``--no-install`` is
-        specified and the virtualenv is being reused.
+        specified and the virtualenv is being reused. (In nox 2023.04.22 and
+        earlier, this was called ``run_always``, and that continues to be
+        available as an alias.)
 
         Here are some cases where this method is useful:
 
@@ -447,7 +449,7 @@ class Session:
             return None
 
         if not args:
-            raise ValueError("At least one argument required to run_always().")
+            raise ValueError("At least one argument required to run_install().")
 
         return self._run(
             *args,
@@ -456,6 +458,22 @@ class Session:
             **kwargs,
         )
 
+    def run_always(
+        self,
+        *args: str | os.PathLike[str],
+        env: Mapping[str, str] | None = None,
+        include_outer_env: bool = True,
+        **kwargs: Any,
+    ) -> Any | None:
+        """This is an alias to ``run_install``, which better describes the use case.
+
+        :meta private:
+        """
+
+        return self.run_install(
+            *args, env=env, include_outer_env=include_outer_env, **kwargs
+        )
+
     def _run(
         self,
         *args: str | os.PathLike[str],
diff --git a/nox/tox_to_nox.py b/nox/tox_to_nox.py
index c68b13a..52a322f 100644
--- a/nox/tox_to_nox.py
+++ b/nox/tox_to_nox.py
@@ -31,10 +31,12 @@ _TEMPLATE = jinja2.Template(
 
 
 def wrapjoin(seq: Iterator[Any]) -> str:
+    """Wrap each item in single quotes and join them with a comma."""
     return ", ".join([f"'{item}'" for item in seq])
 
 
 def fixname(envname: str) -> str:
+    """Replace dashes with underscores and check if the result is a valid identifier."""
     envname = envname.replace("-", "_")
     if not envname.isidentifier():
         print(
@@ -44,6 +46,12 @@ def fixname(envname: str) -> str:
     return envname
 
 
+def write_output_to_file(output: str, filename: str) -> None:
+    """Write output to a file."""
+    with open(filename, "w") as outfile:
+        outfile.write(output)
+
+
 def main() -> None:
     parser = argparse.ArgumentParser(description="Converts toxfiles to noxfiles.")
     parser.add_argument("--output", default="noxfile.py")
@@ -53,5 +61,4 @@ def main() -> None:
     config = tox.config.parseconfig([])
     output = _TEMPLATE.render(config=config, wrapjoin=wrapjoin, fixname=fixname)
 
-    with open(args.output, "w") as outfile:
-        outfile.write(output)
+    write_output_to_file(output, args.output)
diff --git a/nox/workflow.py b/nox/workflow.py
index c50bfbc..3b65325 100644
--- a/nox/workflow.py
+++ b/nox/workflow.py
@@ -49,7 +49,8 @@ def execute(
             args: list[Any] = []
             if return_value is not None:
                 args.append(return_value)
-            return_value = function_(*args, global_config=global_config)
+            kwargs: dict[str, Any] = {"global_config": global_config}
+            return_value = function_(*args, **kwargs)
 
             # If we got an integer value as a result, abort task processing
             # and return it.
diff --git a/noxfile.py b/noxfile.py
index 3d2a65e..7fac569 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -31,7 +31,7 @@ if shutil.which("conda"):
     nox.options.sessions.append("conda_tests")
 
 
-@nox.session(python=["3.7", "3.8", "3.9", "3.10", "3.11"])
+@nox.session(python=["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"])
 def tests(session: nox.Session) -> None:
     """Run test suite with pytest."""
     session.create_tmp()  # Fixes permission errors on Windows
@@ -119,10 +119,8 @@ def _check_python_version(session: nox.Session) -> None:
     session.run(
         "python",
         "-c",
-        (
-            "import sys; assert '.'.join(str(v) for v in sys.version_info[:2]) =="
-            f" '{python_version}'"
-        ),
+        "import sys; assert '.'.join(str(v) for v in sys.version_info[:2]) =="
+        f" '{python_version}'",
     )
     if python_version[:2] != "2.":
         session.run(
@@ -133,7 +131,15 @@ def _check_python_version(session: nox.Session) -> None:
 
 
 @nox.session(
-    python=["3.7", "3.8", "3.9", "3.10", "3.11", "pypy3.7", "pypy3.8", "pypy3.9"]
+    python=[
+        "3.8",
+        "3.9",
+        "3.10",
+        "3.11",
+        "3.12",
+        "pypy3.9",
+        "pypy3.10",
+    ]
 )
 def github_actions_default_tests(session: nox.Session) -> None:
     """Check default versions installed by the nox GHA Action"""
@@ -153,6 +159,7 @@ def github_actions_default_tests(session: nox.Session) -> None:
         "pypy3.7",
         "pypy3.8",
         "pypy3.9",
+        "pypy3.10",
     ]
 )
 def github_actions_all_tests(session: nox.Session) -> None:
diff --git a/pyproject.toml b/pyproject.toml
index 6163b16..585efc2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -30,13 +30,13 @@ classifiers = [
   "Operating System :: POSIX",
   "Operating System :: Unix",
   "Programming Language :: Python",
-  "Programming Language :: Python :: 3",
   "Programming Language :: Python :: 3 :: Only",
   "Programming Language :: Python :: 3.7",
   "Programming Language :: Python :: 3.8",
   "Programming Language :: Python :: 3.9",
   "Programming Language :: Python :: 3.10",
   "Programming Language :: Python :: 3.11",
+  "Programming Language :: Python :: 3.12",
   "Topic :: Software Development :: Testing",
 ]
 dependencies = [
@@ -45,28 +45,46 @@ dependencies = [
   'importlib-metadata; python_version < "3.8"',
   "packaging>=20.9",
   'typing-extensions>=3.7.4; python_version < "3.8"',
-  "virtualenv>=14",
+  "virtualenv>=20.14.1",
 ]
 [project.optional-dependencies]
 tox_to_nox = [
   "jinja2",
   "tox<4",
 ]
-
 [project.urls]
 bug-tracker = "https://github.com/wntrblm/nox/issues"
 documentation = "https://nox.thea.codes"
 homepage = "https://github.com/wntrblm/nox"
 repository = "https://github.com/wntrblm/nox"
-
 [project.scripts]
 nox = "nox.__main__:main"
 tox-to-nox = "nox.tox_to_nox:main"
 
-
 [tool.hatch]
 metadata.allow-ambiguous-features = true # disable normalization (tox-to-nox) for back-compat
 
+[tool.ruff]
+target-version = "py37"
+
+[tool.ruff.lint]
+extend-select = [
+  "B",           # flake8-bugbear
+  "I",           # isort
+  "C4",          # flake8-comprehensions
+  "ICN",         # flake8-import-conventions
+  "ISC",         # flake8-implicit-str-concat
+  "PGH",         # pygrep-hooks
+  "PIE",         # flake8-pie
+  "RUF",         # Ruff-specific
+  "SIM",         # flake8-simplify
+  "UP",          # pyupgrade
+  "YTT",         # flake8-2020
+]
+ignore = [
+  "ISC001", # Conflicts with formatter
+]
+
 [tool.pytest.ini_options]
 minversion = "6.0"
 addopts = [ "-ra", "--strict-markers", "--strict-config" ]
@@ -93,22 +111,3 @@ enable_error_code = [ "ignore-without-code", "redundant-expr", "truthy-bool" ]
 [[tool.mypy.overrides]]
 module = [ "argcomplete", "colorlog.*", "py", "tox.*" ]
 ignore_missing_imports = true
-
-[tool.ruff]
-select = [
-  "E", "F", "W", # flake8
-  "B", "B904",   # flake8-bugbear
-  "I",           # isort
-  "C4",          # flake8-comprehensions
-  "ICN",         # flake8-import-conventions
-  "ISC",         # flake8-implicit-str-concat
-  "PGH",         # pygrep-hooks
-  "PIE",         # flake8-pie
-  "RUF",         # Ruff-specific
-  "SIM",         # flake8-simplify
-  "UP",          # pyupgrade
-  "YTT",         # flake8-2020
-]
-extend-ignore = ["E501"]
-target-version = "py37"
-exclude = []


Test patch:
diff --git a/requirements-conda-test.txt b/requirements-conda-test.txt
index 156a932..8dde50e 100644
--- a/requirements-conda-test.txt
+++ b/requirements-conda-test.txt
@@ -3,4 +3,4 @@ colorlog >=2.6.1,<7.0.0
 jinja2
 pytest
 tox<4.0.0
-virtualenv >=14.0.0
+virtualenv >=20.14.1
diff --git a/tests/resources/noxfile_pythons.py b/tests/resources/noxfile_pythons.py
index 63cb8d9..8dd3551 100644
--- a/tests/resources/noxfile_pythons.py
+++ b/tests/resources/noxfile_pythons.py
@@ -7,3 +7,13 @@ import nox
 @nox.parametrize("cheese", ["cheddar", "jack", "brie"])
 def snack(unused_session, cheese):
     print(f"Noms, {cheese} so good!")
+
+
+@nox.session(python=False)
+def nopy(unused_session):
+    print("No pythons here.")
+
+
+@nox.session(python="3.12")
+def strpy(unused_session):
+    print("Python-in-a-str here.")
diff --git a/tests/resources/noxfile_tags.py b/tests/resources/noxfile_tags.py
new file mode 100644
index 0000000..bf43d99
--- /dev/null
+++ b/tests/resources/noxfile_tags.py
@@ -0,0 +1,18 @@
+from __future__ import annotations
+
+import nox
+
+
+@nox.session  # no tags
+def no_tags(unused_session):
+    print("Look ma, no tags!")
+
+
+@nox.session(tags=["tag1"])
+def one_tag(unused_session):
+    print("Lonesome tag here.")
+
+
+@nox.session(tags=["tag1", "tag2", "tag3"])
+def moar_tags(unused_session):
+    print("Some more tags here.")
diff --git a/tests/test__option_set.py b/tests/test__option_set.py
index 93a768a..c474d12 100644
--- a/tests/test__option_set.py
+++ b/tests/test__option_set.py
@@ -95,7 +95,7 @@ class TestOptionSet:
         )
 
         expected_sessions = ["testytest", "lintylint", "typeytype"]
-        assert expected_sessions == actual_sessions_from_file
+        assert expected_sessions == list(actual_sessions_from_file)
 
     def test_session_completer_invalid_sessions(self):
         parsed_args = _options.options.namespace(
@@ -105,3 +105,27 @@ class TestOptionSet:
             prefix=None, parsed_args=parsed_args
         )
         assert len(all_nox_sessions) == 0
+
+    def test_python_completer(self):
+        parsed_args = _options.options.namespace(
+            posargs=[],
+            noxfile=str(RESOURCES.joinpath("noxfile_pythons.py")),
+        )
+        actual_pythons_from_file = _options._python_completer(
+            prefix=None, parsed_args=parsed_args
+        )
+
+        expected_pythons = {"3.6", "3.12"}
+        assert expected_pythons == set(actual_pythons_from_file)
+
+    def test_tag_completer(self):
+        parsed_args = _options.options.namespace(
+            posargs=[],
+            noxfile=str(RESOURCES.joinpath("noxfile_tags.py")),
+        )
+        actual_tags_from_file = _options._tag_completer(
+            prefix=None, parsed_args=parsed_args
+        )
+
+        expected_tags = {"tag1", "tag2", "tag3"}
+        assert expected_tags == set(actual_tags_from_file)
diff --git a/tests/test__parametrize.py b/tests/test__parametrize.py
index 1364087..41ea423 100644
--- a/tests/test__parametrize.py
+++ b/tests/test__parametrize.py
@@ -35,8 +35,7 @@ def test_param_eq(param, other, expected):
 
 
 def test_param_eq_fail():
-    with pytest.raises(NotImplementedError):
-        _parametrize.Param() == "a"  # noqa: B015
+    assert _parametrize.Param() != "a"
 
 
 def test_parametrize_decorator_one():
diff --git a/tests/test_action_helper.py b/tests/test_action_helper.py
index 983c944..44cfafb 100644
--- a/tests/test_action_helper.py
+++ b/tests/test_action_helper.py
@@ -62,7 +62,8 @@ VALID_VERSION_LISTS = {
     ",".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)
-    ] + ["3.11"],
+    ]
+    + ["3.11"],
 }
 
 
diff --git a/tests/test_manifest.py b/tests/test_manifest.py
index 5d87bfd..5452288 100644
--- a/tests/test_manifest.py
+++ b/tests/test_manifest.py
@@ -49,6 +49,7 @@ def create_mock_config():
     cfg.force_venv_backend = None
     cfg.default_venv_backend = None
     cfg.extra_pythons = None
+    cfg.force_pythons = None
     cfg.posargs = []
     return cfg
 
@@ -287,6 +288,41 @@ def test_extra_pythons(python, extra_pythons, expected):
     assert expected == [session.func.python for session in manifest._all_sessions]
 
 
+@pytest.mark.parametrize(
+    "python,force_pythons,expected",
+    [
+        (None, [], [None]),
+        (None, ["3.8"], ["3.8"]),
+        (None, ["3.8", "3.9"], ["3.8", "3.9"]),
+        (False, [], [False]),
+        (False, ["3.8"], ["3.8"]),
+        (False, ["3.8", "3.9"], ["3.8", "3.9"]),
+        ("3.5", [], ["3.5"]),
+        ("3.5", ["3.8"], ["3.5", "3.8"]),
+        ("3.5", ["3.8", "3.9"], ["3.5", "3.8", "3.9"]),
+        (["3.5", "3.9"], [], ["3.5", "3.9"]),
+        (["3.5", "3.9"], ["3.8"], ["3.5", "3.9", "3.8"]),
+        (["3.5", "3.9"], ["3.8", "3.4"], ["3.5", "3.9", "3.8", "3.4"]),
+        (["3.5", "3.9"], ["3.5", "3.9"], ["3.5", "3.9"]),
+    ],
+)
+def test_force_pythons(python, force_pythons, expected):
+    cfg = create_mock_config()
+    cfg.force_pythons = force_pythons
+    cfg.extra_pythons = force_pythons
+
+    manifest = Manifest({}, cfg)
+
+    def session_func():
+        pass
+
+    func = Func(session_func, python=python)
+    for session in manifest.make_session("my_session", func):
+        manifest.add_session(session)
+
+    assert expected == [session.func.python for session in manifest._all_sessions]
+
+
 def test_add_session_parametrized():
     manifest = Manifest({}, create_mock_config())
 
diff --git a/tests/test_sessions.py b/tests/test_sessions.py
index 27bd8b8..1926a33 100644
--- a/tests/test_sessions.py
+++ b/tests/test_sessions.py
@@ -123,7 +123,7 @@ class TestSession:
         with pytest.raises(
             ValueError, match=r"^The environment does not have a bin directory\.$"
         ):
-            session.bin
+            session.bin  # noqa: B018
         assert session.bin_paths is None
 
     def test_virtualenv_as_none(self):
@@ -358,25 +358,25 @@ class TestSession:
         with pytest.raises(nox.command.CommandFailed, match="External"):
             session.run(sys.executable, "--version")
 
-    def test_run_always_bad_args(self):
+    def test_run_install_bad_args(self):
         session, _ = self.make_session_and_runner()
 
         with pytest.raises(ValueError) as exc_info:
-            session.run_always()
+            session.run_install()
 
         exc_args = exc_info.value.args
-        assert exc_args == ("At least one argument required to run_always().",)
+        assert exc_args == ("At least one argument required to run_install().",)
 
-    def test_run_always_success(self):
+    def test_run_install_success(self):
         session, _ = self.make_session_and_runner()
 
-        assert session.run_always(operator.add, 1300, 37) == 1337
+        assert session.run_install(operator.add, 1300, 37) == 1337
 
-    def test_run_always_install_only(self, caplog):
+    def test_run_install_install_only(self, caplog):
         session, runner = self.make_session_and_runner()
         runner.global_config.install_only = True
 
-        assert session.run_always(operator.add, 23, 19) == 42
+        assert session.run_install(operator.add, 23, 19) == 42
 
     @pytest.mark.parametrize(
         (
@@ -437,13 +437,17 @@ class TestSession:
             (False, False, True),
         ],
     )
-    def test_run_always_no_install(self, no_install, reused, run_called):
+    @pytest.mark.parametrize("run_install_func", ["run_always", "run_install"])
+    def test_run_install_no_install(
+        self, no_install, reused, run_called, run_install_func
+    ):
         session, runner = self.make_session_and_runner()
         runner.global_config.no_install = no_install
         runner.venv._reused = reused
 
         with mock.patch.object(nox.command, "run") as run:
-            session.run_always("python", "-m", "pip", "install", "requests")
+            run_install = getattr(session, run_install_func)
+            run_install("python", "-m", "pip", "install", "requests")
 
         assert run.called is run_called
 
@@ -799,7 +803,7 @@ class TestSession:
         with pytest.raises(AttributeError):
             session.foo = "bar"
         with pytest.raises(AttributeError):
-            session.quux
+            session.quux  # noqa: B018
 
     def test___dict__(self):
         session, _ = self.make_session_and_runner()
@@ -1057,10 +1061,8 @@ class TestSessionRunner:
             session.run(
                 sys.executable,
                 "-c",
-                (
-                    "import os; raise SystemExit(0 if"
-                    f' os.environ["NOX_CURRENT_SESSION"] == {session.name!r} else 0)'
-                ),
+                "import os; raise SystemExit(0 if"
+                f' os.environ["NOX_CURRENT_SESSION"] == {session.name!r} else 0)',
             )
 
         runner.func = func
diff --git a/tests/test_tox_to_nox.py b/tests/test_tox_to_nox.py
index 4419d1a..7636855 100644
--- a/tests/test_tox_to_nox.py
+++ b/tests/test_tox_to_nox.py
@@ -307,8 +307,7 @@ def test_non_identifier_in_envname(makeconfig, capfd):
     out, _ = capfd.readouterr()
 
     assert (
-        out
-        == "Environment 'test_with_&' is not a valid nox session name.\n"
+        out == "Environment 'test_with_&' is not a valid nox session name.\n"
         "Manually update the session name in noxfile.py before running nox.\n"
     )
 
diff --git a/tests/test_virtualenv.py b/tests/test_virtualenv.py
index ddc0172..3a02a7d 100644
--- a/tests/test_virtualenv.py
+++ b/tests/test_virtualenv.py
@@ -103,7 +103,7 @@ def test_process_env_constructor():
     with pytest.raises(
         ValueError, match=r"^The environment does not have a bin directory\.$"
     ):
-        penv.bin
+        penv.bin  # noqa: B018
 
     penv = nox.virtualenv.ProcessEnv(env={"SIGIL": "123"})
     assert penv.env["SIGIL"] == "123"
@@ -276,7 +276,7 @@ def test_blacklisted_env(monkeypatch, make_one):
 def test__clean_location(monkeypatch, make_one):
     venv, dir_ = make_one()
 
-    # Don't re-use existing, but doesn't currently exist.
+    # Don't reuse existing, but doesn't currently exist.
     # Should return True indicating that the venv needs to be created.
     monkeypatch.setattr(
         nox.virtualenv.VirtualEnv, "_check_reused_environment_type", mock.MagicMock()
@@ -290,14 +290,14 @@ def test__clean_location(monkeypatch, make_one):
     assert not dir_.check()
     assert venv._clean_location()
 
-    # Re-use existing, and currently exists.
+    # Reuse existing, and currently exists.
     # Should return False indicating that the venv doesn't need to be created.
     dir_.mkdir()
     assert dir_.check()
     venv.reuse_existing = True
     assert not venv._clean_location()
 
-    # Don't re-use existing, and currently exists.
+    # Don't reuse existing, and currently exists.
     # Should return True indicating the venv needs to be created.
     monkeypatch.undo()
     assert dir_.check()
@@ -305,7 +305,7 @@ def test__clean_location(monkeypatch, make_one):
     assert venv._clean_location()
     assert not dir_.check()
 
-    # Re-use existing, but doesn't exist.
+    # Reuse existing, but doesn't exist.
     # Should return True indicating the venv needs to be created.
     venv.reuse_existing = True
     assert venv._clean_location()
@@ -561,7 +561,7 @@ def test__resolved_interpreter_invalid_numerical_id(which, make_one, input_):
     venv, _ = make_one(interpreter=input_)
 
     with pytest.raises(nox.virtualenv.InterpreterNotFound):
-        venv._resolved_interpreter
+        venv._resolved_interpreter  # noqa: B018
 
     which.assert_called_once_with(input_)
 
@@ -572,7 +572,7 @@ def test__resolved_interpreter_32_bit_non_windows(which, make_one):
     venv, _ = make_one(interpreter="3.6-32")
 
     with pytest.raises(nox.virtualenv.InterpreterNotFound):
-        venv._resolved_interpreter
+        venv._resolved_interpreter  # noqa: B018
     which.assert_called_once_with("3.6-32")
 
 
@@ -654,7 +654,7 @@ def test__resolved_interpreter_windows_pyexe_fails(which, run, make_one):
 
     # Okay now run the test.
     with pytest.raises(nox.virtualenv.InterpreterNotFound):
-        venv._resolved_interpreter
+        venv._resolved_interpreter  # noqa: B018
 
     which.assert_has_calls([mock.call("python3.6"), mock.call("py")])
 
@@ -700,7 +700,7 @@ def test__resolved_interpreter_windows_path_and_version_fails(
     patch_sysfind(("python", "python.exe"), sysfind_result, sysexec_result)
 
     with pytest.raises(nox.virtualenv.InterpreterNotFound):
-        venv._resolved_interpreter
+        venv._resolved_interpreter  # noqa: B018
 
 
 @mock.patch("nox.virtualenv._SYSTEM", new="Windows")
@@ -715,7 +715,7 @@ def test__resolved_interpreter_not_found(which, make_one):
 
     # Run the test.
     with pytest.raises(nox.virtualenv.InterpreterNotFound):
-        venv._resolved_interpreter
+        venv._resolved_interpreter  # noqa: B018
 
 
 @mock.patch("nox.virtualenv._SYSTEM", new="Windows")
@@ -725,7 +725,7 @@ def test__resolved_interpreter_nonstandard(make_one):
     venv, _ = make_one(interpreter="goofy")
 
     with pytest.raises(nox.virtualenv.InterpreterNotFound):
-        venv._resolved_interpreter
+        venv._resolved_interpreter  # noqa: B018
 
 
 @mock.patch("nox.virtualenv._SYSTEM", new="Linux")
@@ -749,12 +749,12 @@ def test__resolved_interpreter_cache_failure(which, make_one):
 
     assert venv._resolved is None
     with pytest.raises(nox.virtualenv.InterpreterNotFound) as exc_info:
-        venv._resolved_interpreter
+        venv._resolved_interpreter  # noqa: B018
     caught = exc_info.value
 
     which.assert_called_once_with("3.7-32")
     # Check the cache and call again to make sure it is used.
     assert venv._resolved is caught
     with pytest.raises(nox.virtualenv.InterpreterNotFound):
-        venv._resolved_interpreter
+        venv._resolved_interpreter  # noqa: B018
     assert which.call_count == 1