Repository: wntrblm/nox
Pull request #687 (2023-01-24 14:59:20)

First implementation of tox_to_nox for tox 4
Fixes: #673 

It seems to me that the easiest way forward is to call `tox config`, parse the output and then preprocess it into a dictionary so it can be easily used in the jinja template.

What do you think?

From this tox.ini:
```
[tox]
envlist = black,mypy,py{36,37,38,39,310,311,312}
isolated_build = True

[testenv]
commands =
    python -m platform
    pytest -v {posargs} test.py
deps =
    pytest

[testenv:black]
description="better than blue"
skip_install=true
deps=black
commands=black --check --diff --extend-exclude=".tox|test" ./

[testenv:mypy]
use_develop=true
deps=mypy
commands=mypy --strict -p marshalparser
```
it now generates this noxfile.py
```python
import nox


@nox.session()
def DEFAULT(session):
    """"""
    session.install('.')

@nox.session(python='/home/lbalhar/.virtualenvs/nox/bin/python')
def testenv_black(session):
    """"better than blue""""
    session.env['PIP_DISABLE_PIP_VERSION_CHECK'] = '1'
    session.env['PYTHONIOENCODING'] = 'utf-8'
    session.install('black')
    session.run('black --check --diff '--extend-exclude=.tox|test' ./')

@nox.session(python='/home/lbalhar/.virtualenvs/nox/bin/python')
def testenv_mypy(session):
    session.env['PIP_DISABLE_PIP_VERSION_CHECK'] = '1'
    session.env['PYTHONIOENCODING'] = 'utf-8'
    session.install('mypy')
    session.install('-e', '.')
    session.run('mypy --strict -p marshalparser')

@nox.session(python='py310')
def testenv_py310(session):
    session.env['PIP_DISABLE_PIP_VERSION_CHECK'] = '1'
    session.env['PYTHONIOENCODING'] = 'utf-8'
    session.install('pytest')
    session.install('.')
    session.run('python -m platform')
    session.run('pytest -v test.py')

@nox.session(python='py311')
def testenv_py311(session):
    session.env['PIP_DISABLE_PIP_VERSION_CHECK'] = '1'
    session.env['PYTHONIOENCODING'] = 'utf-8'
    session.install('pytest')
    session.install('.')
    session.run('python -m platform')
    session.run('pytest -v test.py')

@nox.session(python='py312')
def testenv_py312(session):
    session.env['PIP_DISABLE_PIP_VERSION_CHECK'] = '1'
    session.env['PYTHONIOENCODING'] = 'utf-8'
    session.install('pytest')
    session.install('.')
    session.run('python -m platform')
    session.run('pytest -v test.py')

@nox.session(python='py36')
def testenv_py36(session):
    session.env['PIP_DISABLE_PIP_VERSION_CHECK'] = '1'
    session.env['PYTHONIOENCODING'] = 'utf-8'
    session.install('pytest')
    session.install('.')
    session.run('python -m platform')
    session.run('pytest -v test.py')

@nox.session(python='py37')
def testenv_py37(session):
    session.env['PIP_DISABLE_PIP_VERSION_CHECK'] = '1'
    session.env['PYTHONIOENCODING'] = 'utf-8'
    session.install('pytest')
    session.install('.')
    session.run('python -m platform')
    session.run('pytest -v test.py')

@nox.session(python='py38')
def testenv_py38(session):
    session.env['PIP_DISABLE_PIP_VERSION_CHECK'] = '1'
    session.env['PYTHONIOENCODING'] = 'utf-8'
    session.install('pytest')
    session.install('.')
    session.run('python -m platform')
    session.run('pytest -v test.py')

@nox.session(python='py39')
def testenv_py39(session):
    session.env['PIP_DISABLE_PIP_VERSION_CHECK'] = '1'
    session.env['PYTHONIOENCODING'] = 'utf-8'
    session.install('pytest')
    session.install('.')
    session.run('python -m platform')
    session.run('pytest -v test.py')

```

I guess that:
* the default env variables should be skipped.
* the default environment should be skipped.

Correct?

frenzymadness: Right now, all the tests are happy with both tox versions. I skipped just the test of the invalid identified in the env name because it seems that tox 4 does not support it.

Another problem is how to include tests with tox 3 into nox's tests. I was thinking about parametrizing the main test session but it seems to be a waste of resources to run all the tests again with tox 3 if the vast majority doesn't depend on tox. My solution is to downgrade tox to version <4 and then run the pytest again in the same session but only the tests of tox_to_nox.
FollowTheProcess: Just looks like a couple of uncovered sections in tox_to_nox
frenzymadness: > Just looks like a couple of uncovered sections in tox_to_nox

What do you mean? I'm looking at both jinja2 templates side-by-side and they both seem to handle the same configuration options from tox.ini.
frenzymadness: The problem reported by coverage was actually caused by two conditions that were never false so I can remove them, nice. All linter should be happy now, at least they are happy on my machine.
frenzymadness: And now we have a different problem with dependencies. argcomplete 2.0.0 requires `importlib-metadata<5,>=0.23; python_version == "3.7"` but the latest tox depends on `importlib-metadata>=6; python_version < "3.8"` which means that they cannot be installed together in an environment with Python 3.7 and therefore both pytest runs tests with tox 3 and hence the coverage is less than 100 %.

We can force install tox 4 in a dedicated step in Python 3.7 env but there is a risk that it breaks nox or its dependencies.

I'd set a lower coverage limit for Python 3.7 or skip the coverage there entirely. What do you think?
frenzymadness: @FollowTheProcess what do you think about the proposed solutions?
FollowTheProcess: Hmm, so this would mean anyone installing Nox on 3.7 would never get tox 4 right? So if that's the case I don't think we need to be super strict on getting to 100% because users would never hit that branch either?

I'd personally be happy to mark that branch as excluded from coverage when on 3.7, with a nice explanatory comment etc. I think coverage allows you to do this, not 100% sure
frenzymadness: Let's see what will CI say now.
frenzymadness: The problem here is in the way how the Github Actions are configured. If you run nox test locally, it's green because the coverage report is combined from all runs with all Python versions and therefore the lower overage from Python 3.7 is not a problem. But in GH actions, the coverage report is divided into jobs for each tested Python version which makes the job for Python 3.7 fail on too low coverage.

I see two ways forward:
1. Do not run coverage for Python 3.7 on GH actions.
2. Reconfigure GH actions to collect coverage for individual jobs and run coverage report for the collected data in a separate job.

What do you prefer?
frenzymadness: I've implemented what I think is the simplest solution here - no `session.notify("cover")` for Python 3.7. That way, the coverage report is run only if called explicitly or if tests run for more than this one environment which should also fix the coverage so it should be green all the time.
frenzymadness: But now we are back at the problem with coverage in GH action. If we split the jobs in GH action in the same way we have them in the parametrized sessions in the noxfile, the coverage will fail for all of them because they won't be tested with both tox versions at the same time.

I'm going to enable GH actions in my fork and try to implement a single job for the coverage reporting.
frenzymadness: Finally, after many failures, it's done. Now, each session in noxfile has its own job in GH actions. The produced coverage files are uploaded by the individual jobs. When all testing jobs finish, the coverage job starts, downloads the files, combines them and provides the report.

Locally, it no longer makes sense to run coverage if you test only one environment, because it cannot be 100 %, so the notification of coverage session has been removed.

You can see the last run in my fork here: https://github.com/frenzymadness/nox/actions/runs/4224370541
mtelka: tox 3 is dead.  Please go forward and merge this.  Thank you.
crwilcox: @FollowTheProcess I think it might be worth 'resurecting' this and merging. There are some straightforward conflicts to handle, and this would be an improvement for users. If you are also on board I can get this over the line if needed.

Thanks @frenzymadness for picking this up.
FollowTheProcess: Yeah, sorry been swamped for a while and not got chance to be around as much as I'd have liked! I'm on board 👍🏻 

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 a7d9827..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:
@@ -34,10 +36,10 @@ jobs:
     runs-on: windows-latest
     steps:
       - uses: actions/checkout@v4
-      - uses: actions/setup-python@v2
+      - 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 5be101a..bd7127f 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"
@@ -18,14 +20,15 @@ jobs:
       matrix:
         os: [ubuntu-20.04, windows-latest, macos-latest]
         python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]
+        tox-version: ["latest", "<4"]
     steps:
       - 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 }}
@@ -35,14 +38,40 @@ jobs:
         run: |
           python -m pip install --disable-pip-version-check .
       - name: Run tests on ${{ matrix.os }}
-        run: nox --non-interactive --error-on-missing-interpreter --session "tests-${{ matrix.python-version }}" -- --full-trace
+        run: nox --non-interactive --error-on-missing-interpreter --session "tests(python='${{ matrix.python-version }}', tox_version='${{ matrix.tox-version }}')" -- --full-trace
+      - name: Save coverage report
+        uses: actions/upload-artifact@v3
+        with:
+          name: coverage
+          path: .coverage.*
+
+  coverage:
+    needs: build
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - name: Set up Python 3.11
+        uses: actions/setup-python@v4
+        with:
+          python-version: "3.11"
+      - name: Install Nox-under-test
+        run: |
+          python -m pip install --disable-pip-version-check .
+      - name: Download individual coverage reports
+        uses: actions/download-artifact@v3
+        with:
+          name: coverage
+      - name: Display structure of downloaded files
+        run: ls -aR
+      - name: Run coverage
+        run: nox --non-interactive --session "cover"
 
   lint:
     runs-on: ubuntu-latest
     steps:
       - 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
@@ -55,7 +84,7 @@ jobs:
     steps:
       - 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
diff --git a/action.yml b/action.yml
index 6b23016..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, 3.12, 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,7 +12,7 @@ branding:
 runs:
   using: composite
   steps:
-    - uses: actions/setup-python@v4
+    - uses: actions/setup-python@v5
       id: localpython
       with:
         python-version: "3.7 - 3.12"
@@ -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 2864e35..bc23761 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -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/_option_set.py b/nox/_option_set.py
index 0ca3793..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
@@ -90,7 +90,7 @@ class Option:
             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
diff --git a/nox/_options.py b/nox/_options.py
index 24987b4..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
 
@@ -227,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(
@@ -298,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",
@@ -307,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",
@@ -317,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",
@@ -421,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",
@@ -430,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"],
@@ -441,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",
@@ -484,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."
         ),
     ),
@@ -494,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/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/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/tox4_to_nox.jinja2 b/nox/tox4_to_nox.jinja2
new file mode 100644
index 0000000..e5a67d9
--- /dev/null
+++ b/nox/tox4_to_nox.jinja2
@@ -0,0 +1,33 @@
+import nox
+
+{% for envname, envconfig in config.items()|sort: %}
+@nox.session({%- if envconfig.base_python %}python='{{envconfig.base_python}}'{%- endif %})
+def {{fixname(envname)}}(session):
+    {%- if envconfig.description != '' %}
+    """{{envconfig.description}}"""
+    {%- endif %}
+    {%- set envs = envconfig.get('set_env', {}) -%}
+    {%- for key, value in envs.items()|sort: %}
+    session.env['{{key}}'] = '{{value}}'
+    {%- endfor %}
+
+    {%- if envconfig.deps %}
+    session.install({{envconfig.deps}})
+    {%- endif %}
+
+    {%- if not envconfig.skip_install %}
+    {%- if envconfig.use_develop %}
+    session.install('-e', '.')
+    {%- else %}
+    session.install('.')
+    {%- endif -%}
+    {%- endif %}
+
+    {%- if envconfig.change_dir %}
+    session.chdir('{{envconfig.change_dir}}')
+    {%- endif %}
+
+    {%- for command in envconfig.commands %}
+    session.run({{command}})
+    {%- endfor %}
+{% endfor %}
diff --git a/nox/tox_to_nox.py b/nox/tox_to_nox.py
index 52a322f..37e184f 100644
--- a/nox/tox_to_nox.py
+++ b/nox/tox_to_nox.py
@@ -17,27 +17,40 @@
 from __future__ import annotations
 
 import argparse
+import os
 import pkgutil
-from collections.abc import Iterator
-from typing import Any
+import re
+from configparser import ConfigParser
+from pathlib import Path
+from subprocess import check_output
+from typing import Any, Iterable
 
 import jinja2
 import tox.config
+from tox import __version__ as TOX_VERSION
 
-_TEMPLATE = jinja2.Template(
-    pkgutil.get_data(__name__, "tox_to_nox.jinja2").decode("utf-8"),  # type: ignore[union-attr]
-    extensions=["jinja2.ext.do"],
-)
+TOX4 = TOX_VERSION[0] == "4"
 
+if TOX4:
+    _TEMPLATE = jinja2.Template(
+        pkgutil.get_data(__name__, "tox4_to_nox.jinja2").decode("utf-8"),  # type: ignore[union-attr]
+        extensions=["jinja2.ext.do"],
+    )
+else:
+    _TEMPLATE = jinja2.Template(
+        pkgutil.get_data(__name__, "tox_to_nox.jinja2").decode("utf-8"),  # type: ignore[union-attr]
+        extensions=["jinja2.ext.do"],
+    )
 
-def wrapjoin(seq: Iterator[Any]) -> str:
+
+def wrapjoin(seq: Iterable[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("-", "_")
+    envname = envname.replace("-", "_").replace("testenv:", "")
     if not envname.isidentifier():
         print(
             f"Environment {envname!r} is not a valid nox session name.\n"
@@ -58,7 +71,61 @@ def main() -> None:
 
     args = parser.parse_args()
 
-    config = tox.config.parseconfig([])
+    if TOX4:
+        output = check_output(["tox", "config"], text=True)
+        original_config = ConfigParser()
+        original_config.read_string(output)
+        config: dict[str, dict[str, Any]] = {}
+
+        for name, section in original_config.items():
+            if name == "DEFAULT":
+                continue
+
+            config[name] = dict(section)
+            # Convert set_env from string to dict
+            set_env = {}
+            for var in section.get("set_env", "").strip().splitlines():
+                k, v = var.split("=")
+                if k not in (
+                    "PYTHONHASHSEED",
+                    "PIP_DISABLE_PIP_VERSION_CHECK",
+                    "PYTHONIOENCODING",
+                ):
+                    set_env[k] = v
+
+            config[name]["set_env"] = set_env
+
+            config[name]["commands"] = [
+                wrapjoin(c.split()) for c in section["commands"].strip().splitlines()
+            ]
+
+            config[name]["deps"] = wrapjoin(section["deps"].strip().splitlines())
+
+            for option in "skip_install", "use_develop":
+                if section.get(option):
+                    if section[option] == "False":
+                        config[name][option] = False
+                    else:
+                        config[name][option] = True
+
+            if os.path.isabs(section["base_python"]) or re.match(
+                r"py\d+", section["base_python"]
+            ):
+                impl = (
+                    "python" if section["py_impl"] == "cpython" else section["py_impl"]
+                )
+                config[name]["base_python"] = impl + section["py_dot_ver"]
+
+            change_dir = Path(section.get("change_dir"))
+            rel_to_cwd = change_dir.relative_to(Path.cwd())
+            if str(rel_to_cwd) == ".":
+                config[name]["change_dir"] = None
+            else:
+                config[name]["change_dir"] = rel_to_cwd
+
+    else:
+        config = tox.config.parseconfig([])
+
     output = _TEMPLATE.render(config=config, wrapjoin=wrapjoin, fixname=fixname)
 
     write_output_to_file(output, args.output)
diff --git a/noxfile.py b/noxfile.py
index 3ebed4b..fa10e1f 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -31,12 +31,29 @@ if shutil.which("conda"):
     nox.options.sessions.append("conda_tests")
 
 
-@nox.session(python=["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"])
-def tests(session: nox.Session) -> None:
+@nox.session
+@nox.parametrize(
+    "python, tox_version",
+    [
+        (python, tox_version)
+        for python in ("3.7", "3.8", "3.9", "3.10", "3.11", "3.12")
+        for tox_version in ("latest", "<4")
+    ],
+)
+def tests(session: nox.Session, tox_version: str) -> None:
     """Run test suite with pytest."""
+    # Because there is a dependency conflict between
+    # argcomplete and the latest tox (both depend on
+    # a different version of importlibmetadata for Py 3.7)
+    # pip installs tox 3 as the latest one for Py 3.7.
+    if session.python == "3.7" and tox_version == "latest":
+        return
+
     session.create_tmp()  # Fixes permission errors on Windows
     session.install("-r", "requirements-test.txt")
     session.install("-e", ".[tox_to_nox]")
+    if tox_version != "latest":
+        session.install(f"tox{tox_version}")
     session.run(
         "pytest",
         "--cov=nox",
@@ -44,9 +61,10 @@ def tests(session: nox.Session) -> None:
         "pyproject.toml",
         "--cov-report=",
         *session.posargs,
-        env={"COVERAGE_FILE": f".coverage.{session.python}"},
+        env={
+            "COVERAGE_FILE": f".coverage.{session.python}.tox.{tox_version.lstrip('<')}"
+        },
     )
-    session.notify("cover")
 
 
 @nox.session(python=["3.7", "3.8", "3.9", "3.10"], venv_backend="conda")
@@ -132,15 +150,13 @@ def _check_python_version(session: nox.Session) -> None:
 
 @nox.session(
     python=[
-        "3.7",
         "3.8",
         "3.9",
         "3.10",
         "3.11",
         "3.12",
-        "pypy3.7",
-        "pypy3.8",
         "pypy3.9",
+        "pypy3.10",
     ]
 )
 def github_actions_default_tests(session: nox.Session) -> None:
@@ -161,6 +177,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 23b9e49..8f60a5d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -45,12 +45,12 @@ 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",
+  "tox",
 ]
 [project.urls]
 bug-tracker = "https://github.com/wntrblm/nox/issues"
@@ -64,9 +64,6 @@ 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
@@ -85,6 +82,12 @@ ignore = [
   "ISC001", # Conflicts with formatter
 ]
 
+[tool.ruff]
+target-version = "py37"
+
+[tool.isort]
+profile = "black"
+
 [tool.pytest.ini_options]
 minversion = "6.0"
 addopts = [ "-ra", "--strict-markers", "--strict-config" ]
@@ -95,6 +98,7 @@ testpaths = [ "tests" ]
 
 [tool.coverage.run]
 branch = true
+relative_files = true
 omit = [ "nox/_typing.py" ]
 
 [tool.coverage.report]


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_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 00a912c..1926a33 100644
--- a/tests/test_sessions.py
+++ b/tests/test_sessions.py
@@ -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
 
diff --git a/tests/test_tox_to_nox.py b/tests/test_tox_to_nox.py
index 7636855..11815b7 100644
--- a/tests/test_tox_to_nox.py
+++ b/tests/test_tox_to_nox.py
@@ -18,9 +18,14 @@ 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"
+PYTHON_VERSION = f"{sys.version_info.major}.{sys.version_info.minor}"
+PYTHON_VERSION_NODOT = PYTHON_VERSION.replace(".", "")
+
 
 @pytest.fixture
 def makeconfig(tmpdir):
@@ -40,9 +45,9 @@ def makeconfig(tmpdir):
 def test_trivial(makeconfig):
     result = makeconfig(
         textwrap.dedent(
-            """
+            f"""
     [tox]
-    envlist = py27
+    envlist = py{PYTHON_VERSION_NODOT}
     """
         )
     )
@@ -50,12 +55,12 @@ def test_trivial(makeconfig):
     assert (
         result
         == textwrap.dedent(
-            """
+            f"""
     import nox
 
 
-    @nox.session(python='python2.7')
-    def py27(session):
+    @nox.session(python='python{PYTHON_VERSION}')
+    def py{PYTHON_VERSION_NODOT}(session):
         session.install('.')
     """
         ).lstrip()
@@ -65,9 +70,9 @@ def test_trivial(makeconfig):
 def test_skipinstall(makeconfig):
     result = makeconfig(
         textwrap.dedent(
-            """
+            f"""
     [tox]
-    envlist = py27
+    envlist = py{PYTHON_VERSION_NODOT}
 
     [testenv]
     skip_install = True
@@ -78,12 +83,12 @@ def test_skipinstall(makeconfig):
     assert (
         result
         == textwrap.dedent(
-            """
+            f"""
     import nox
 
 
-    @nox.session(python='python2.7')
-    def py27(session):
+    @nox.session(python='python{PYTHON_VERSION}')
+    def py{PYTHON_VERSION_NODOT}(session):
     """
         ).lstrip()
     )
@@ -92,9 +97,9 @@ def test_skipinstall(makeconfig):
 def test_usedevelop(makeconfig):
     result = makeconfig(
         textwrap.dedent(
-            """
+            f"""
     [tox]
-    envlist = py27
+    envlist = py{PYTHON_VERSION_NODOT}
 
     [testenv]
     usedevelop = True
@@ -105,12 +110,12 @@ def test_usedevelop(makeconfig):
     assert (
         result
         == textwrap.dedent(
-            """
+            f"""
     import nox
 
 
-    @nox.session(python='python2.7')
-    def py27(session):
+    @nox.session(python='python{PYTHON_VERSION}')
+    def py{PYTHON_VERSION_NODOT}(session):
         session.install('-e', '.')
     """
         ).lstrip()
@@ -120,12 +125,12 @@ def test_usedevelop(makeconfig):
 def test_commands(makeconfig):
     result = makeconfig(
         textwrap.dedent(
-            """
+            f"""
     [tox]
     envlist = lint
 
     [testenv:lint]
-    basepython = python2.7
+    basepython = python{PYTHON_VERSION}
     commands =
         python setup.py check --metadata --restructuredtext --strict
         flake8 \\
@@ -138,11 +143,11 @@ def test_commands(makeconfig):
     assert (
         result
         == textwrap.dedent(
-            """
+            f"""
     import nox
 
 
-    @nox.session(python='python2.7')
+    @nox.session(python='python{PYTHON_VERSION}')
     def lint(session):
         session.install('.')
         session.run('python', 'setup.py', 'check', '--metadata', \
@@ -156,12 +161,12 @@ def test_commands(makeconfig):
 def test_deps(makeconfig):
     result = makeconfig(
         textwrap.dedent(
-            """
+            f"""
     [tox]
     envlist = lint
 
     [testenv:lint]
-    basepython = python2.7
+    basepython = python{PYTHON_VERSION}
     deps =
       flake8
       gcp-devrel-py-tools>=0.0.3
@@ -172,11 +177,11 @@ def test_deps(makeconfig):
     assert (
         result
         == textwrap.dedent(
-            """
+            f"""
     import nox
 
 
-    @nox.session(python='python2.7')
+    @nox.session(python='python{PYTHON_VERSION}')
     def lint(session):
         session.install('flake8', 'gcp-devrel-py-tools>=0.0.3')
         session.install('.')
@@ -188,12 +193,12 @@ def test_deps(makeconfig):
 def test_env(makeconfig):
     result = makeconfig(
         textwrap.dedent(
-            """
+            f"""
     [tox]
     envlist = lint
 
     [testenv:lint]
-    basepython = python2.7
+    basepython = python{PYTHON_VERSION}
     setenv =
         SPHINX_APIDOC_OPTIONS=members,inherited-members,show-inheritance
         TEST=meep
@@ -204,11 +209,11 @@ def test_env(makeconfig):
     assert (
         result
         == textwrap.dedent(
-            """
+            f"""
     import nox
 
 
-    @nox.session(python='python2.7')
+    @nox.session(python='python{PYTHON_VERSION}')
     def lint(session):
         session.env['SPHINX_APIDOC_OPTIONS'] = \
 'members,inherited-members,show-inheritance'
@@ -222,12 +227,12 @@ def test_env(makeconfig):
 def test_chdir(makeconfig):
     result = makeconfig(
         textwrap.dedent(
-            """
+            f"""
     [tox]
     envlist = lint
 
     [testenv:lint]
-    basepython = python2.7
+    basepython = python{PYTHON_VERSION}
     changedir = docs
     """
         )
@@ -236,11 +241,11 @@ def test_chdir(makeconfig):
     assert (
         result
         == textwrap.dedent(
-            """
+            f"""
     import nox
 
 
-    @nox.session(python='python2.7')
+    @nox.session(python='python{PYTHON_VERSION}')
     def lint(session):
         session.install('.')
         session.chdir('docs')
@@ -252,12 +257,12 @@ def test_chdir(makeconfig):
 def test_dash_in_envname(makeconfig):
     result = makeconfig(
         textwrap.dedent(
-            """
+            f"""
             [tox]
             envlist = test-with-dash
 
             [testenv:test-with-dash]
-            basepython = python2.7
+            basepython = python{PYTHON_VERSION}
             """
         )
     )
@@ -265,11 +270,11 @@ def test_dash_in_envname(makeconfig):
     assert (
         result
         == textwrap.dedent(
-            """
+            f"""
         import nox
 
 
-        @nox.session(python='python2.7')
+        @nox.session(python='python{PYTHON_VERSION}')
         def test_with_dash(session):
             session.install('.')
         """
@@ -277,15 +282,16 @@ def test_dash_in_envname(makeconfig):
     )
 
 
+@pytest.mark.skipif(TOX4, reason="Not supported in tox 4.")
 def test_non_identifier_in_envname(makeconfig, capfd):
     result = makeconfig(
         textwrap.dedent(
-            """
+            f"""
             [tox]
             envlist = test-with-&
 
             [testenv:test-with-&]
-            basepython = python2.7
+            basepython = python{PYTHON_VERSION}
             """
         )
     )
@@ -293,11 +299,11 @@ def test_non_identifier_in_envname(makeconfig, capfd):
     assert (
         result
         == textwrap.dedent(
-            """
+            f"""
         import nox
 
 
-        @nox.session(python='python2.7')
+        @nox.session(python='python{PYTHON_VERSION}')
         def test_with_&(session):
             session.install('.')
         """
@@ -315,12 +321,12 @@ def test_non_identifier_in_envname(makeconfig, capfd):
 def test_descriptions_into_docstrings(makeconfig):
     result = makeconfig(
         textwrap.dedent(
-            """
+            f"""
             [tox]
             envlist = lint
 
             [testenv:lint]
-            basepython = python2.7
+            basepython = python{PYTHON_VERSION}
             description =
                 runs the lint action
                 now with an unnecessary second line
@@ -331,11 +337,11 @@ def test_descriptions_into_docstrings(makeconfig):
     assert (
         result
         == textwrap.dedent(
-            """
+            f"""
             import nox
 
 
-            @nox.session(python='python2.7')
+            @nox.session(python='python{PYTHON_VERSION}')
             def lint(session):
                 \"\"\"runs the lint action now with an unnecessary second line\"\"\"
                 session.install('.')