Project Setup

Supporting Multiple Python Versions with nox

Test a Python CLI on every supported Python locally with nox and uv: fresh environments per version, locked and lowest-dependency runs, wheel smoke tests and CI.

Updated

Your CLI supports Python 3.10 through 3.14, but you develop on one of them, and your .venv has accumulated packages from months of experiments. Every so often CI reports a failure on 3.10 — a match statement edge case, a stdlib function that does not exist yet, a dependency that dropped support — and you push speculative fixes until it goes green. nox moves that matrix onto your machine: a noxfile.py in plain Python defines sessions such as "tests" or "lint", each runs in a fresh virtual environment for each Python version you list, and the same file drives CI. With uv as the backend, creating those environments takes a second or two. This guide builds a noxfile for a CLI with locked test sessions across versions, a lowest-dependency session, lint, and a smoke test of the built wheel. It belongs to the virtual environments and isolation topic.

Prerequisites

  • A CLI project managed with uv, with uv.lock committed and a dev dependency group containing pytest.
  • nox, run on demand with uvx nox (no installation needed), version 2024.3 or newer for the uv backend.
  • uv installs any Python versions nox asks for, so you do not need them installed beforehand.

What nox adds

A virtual environment isolates one project from another; nox isolates one run from another. Each session gets a newly created environment containing exactly what the session installs, so results never depend on something left over from last week — the same property CI has, available locally.

What one nox session does A nox session creates a fresh virtual environment for a Python version, installs the project and test dependencies, then runs the command. What one nox session does Pick Python from the list Fresh venv uv backend Install project + group Run pytest, mytool per version fast with uv report Fresh environments per run mean results never depend on what was installed last week.

The recipe

# noxfile.py
from pathlib import Path

import nox

nox.options.default_venv_backend = "uv"
nox.options.sessions = ["tests", "lint"]        # what plain `nox` runs

PYTHONS = ["3.10", "3.11", "3.12", "3.13", "3.14"]


def sync(session: nox.Session, *groups: str) -> None:
    """Install the project and groups from uv.lock into this session's environment."""
    session.run_install(
        "uv", "sync", "--locked",
        *(f"--group={g}" for g in groups),
        f"--python={session.virtualenv.location}",
        env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location},
    )


@nox.session(python=PYTHONS)
def tests(session: nox.Session) -> None:
    """Run the test suite with locked dependencies."""
    sync(session, "dev")
    session.run("pytest", "-q", *session.posargs)


@nox.session(python=PYTHONS[0])
def tests_lowest_deps(session: nox.Session) -> None:
    """Oldest Python with the lowest versions our constraints allow."""
    session.install("--resolution=lowest-direct", "-e", ".", "pytest>=8")
    session.run("pytest", "-q", *session.posargs)


@nox.session(python=PYTHONS[-1])
def lint(session: nox.Session) -> None:
    """Ruff and mypy, once, on the newest Python."""
    sync(session, "dev")
    session.run("ruff", "check", ".")
    session.run("ruff", "format", "--check", ".")
    session.run("mypy", "src")


@nox.session(python=[PYTHONS[0], PYTHONS[-1]])
def smoke(session: nox.Session) -> None:
    """Build the wheel, install it alone, and run the command from elsewhere."""
    dist = Path(session.create_tmp()) / "dist"
    session.run("uv", "build", "--wheel", "--out-dir", str(dist), external=True)
    session.install(str(next(dist.glob("*.whl"))))
    with session.chdir(session.create_tmp()):
        session.run("mytool", "--version")
        session.run("mytool", "--help", silent=True)

How it fits together

Sessions across Python versions A nox configuration for a CLI project showing which sessions run on which Python versions. Sessions across Python versions Session 3.10 3.12 3.14 tests tests_lowest_deps lint smoke (built wheel) Version-sensitive sessions run everywhere; version-agnostic ones run once.

default_venv_backend = "uv" makes nox create environments with uv and install with uv pip, and lets uv find or download each Python version on demand. A session that needed thirty seconds with virtualenv and pip takes a couple of seconds.

The sync helper installs from uv.lock rather than resolving afresh. Pointing UV_PROJECT_ENVIRONMENT at the session's environment makes uv sync populate nox's environment instead of the project .venv, and --locked fails if the lockfile is stale — so local sessions test exactly the versions CI and releases use.

tests_lowest_deps deliberately does not use the lockfile. --resolution=lowest-direct installs the oldest version of each direct dependency your constraints allow, on your oldest Python. It is the only way to discover that you declared typer>=0.12 but used something added in 0.15. Note that every dependency it installs needs a lower bound — an unbounded pytest would resolve to a release from 2010.

lint runs once. Linting and type-checking do not depend on the runtime Python in any way that matters day to day, so running them on every version wastes time. Type-check against the oldest version via python_version in the mypy configuration instead.

smoke is the local version of smoke-testing the built wheel in CI: build, install the wheel on its own into a fresh environment, and run the command from a temporary directory where the source tree cannot be imported by accident.

Running it

uvx nox                          # default sessions: tests on every Python, then lint
uvx nox -s tests-3.10            # one session, one version
uvx nox -s tests -- -k config    # everything after -- goes to pytest via session.posargs
uvx nox -s smoke lint            # several sessions
uvx nox -R -s tests-3.12         # reuse the existing environment, skip installs: fast reruns
uvx nox --list                   # what is defined
Running the matrix locally Terminal output of listing nox sessions and running the tests session across Python versions with the uv backend. Running the matrix locally bash $ uvx nox --list * tests-3.10 * tests-3.12 * tests-3.14 * lint * smoke-3.10 * smoke-3.14 $ uvx nox -s tests nox > Session tests-3.10 was successful in 3 seconds. nox > Session tests-3.14 failed. The same sessions run in CI, so a local green run predicts a green pipeline.

-R (reuse and skip install) is the everyday speed-up: after the first run, rerunning a single failing session takes as long as the tests themselves.

Sessions beyond tests

Once a noxfile exists, it becomes the natural home for every repeatable developer task, because each gets a clean, documented environment for free:

  • docs builds the documentation, including a CLI reference generated from the real command tree, and fails on warnings. See generating man pages and docs from a CLI.
  • completion regenerates bundled shell-completion scripts for bash, zsh and fish, so they never go stale relative to the commands.
  • release_check builds the sdist and wheel, runs twine check, and verifies that the version matches the changelog's newest entry before anyone pushes a tag.
  • bench times mytool --help with hyperfine or a small loop, catching startup regressions of the kind described in profiling Python CLI startup time.

Leave these out of nox.options.sessions so the default run stays fast, and give each a docstring so uvx nox --list doubles as a list of project chores.

Using the same sessions in CI

The value of nox grows when CI runs the same sessions, so a green local run predicts a green pipeline:

# .github/workflows/ci.yml (excerpt)
  nox:
    strategy:
      fail-fast: false
      matrix:
        session: ["tests-3.10", "tests-3.12", "tests-3.14", "tests_lowest_deps", "lint"]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v6
        with: { enable-cache: true }
      - run: uvx nox -s ${{ matrix.session }}

Windows and macOS jobs can run a subset (tests-3.10, tests-3.14), following the matrix strategy in testing a CLI across Python versions with GitHub Actions. Keeping the list of versions in one place — PYTHONS in the noxfile — and generating the CI matrix from uvx nox --list --json avoids the two drifting apart on larger projects.

UX considerations

The users of a noxfile are contributors, and it should make the right thing easy:

  • Make the default useful. Plain uvx nox should run what a contributor needs before pushing — usually tests and lint — without the slow sessions.
  • Name sessions for their purpose. tests_lowest_deps and smoke explain themselves in --list; docstrings appear there too.
  • Pass arguments through. session.posargs lets people run a single test file on every Python version without editing anything.
  • Document it in one line. "Run uvx nox before pushing" in CONTRIBUTING.md replaces a page of environment setup instructions, because nox creates everything it needs.

Testing the behaviour

A noxfile is code that runs rarely enough to rot quietly. Two habits keep it working: run uvx nox --list in CI's lint job, which fails on syntax errors and import problems in the noxfile itself, and run the full default set occasionally on a clean checkout (git clean -xdf first, or in a fresh clone) so leftover state cannot mask a broken session. When adding a Python version, add it to PYTHONS, run uvx nox -s tests-3.15 locally, and only then add it to requires-python and the trove classifiers — the order that guarantees you never claim support you have not tested.

Conclusion

nox brings the CI matrix to your laptop: one Python file, sessions in fresh environments for each version, and uv making those environments nearly free to create. Install from the lockfile for ordinary test sessions, add one lowest-dependency session to keep your lower bounds honest, run lint once, smoke-test the built wheel, and have CI call the same sessions. "It passes on my machine" then means the same thing as "it passes in CI".

Frequently asked questions

nox or tox?

Both do the job. tox is configured in INI or TOML and has a large plugin ecosystem; nox is configured in Python, which makes conditional logic and helpers such as sync above straightforward. With the tox-uv plugin, tox gets the same speed benefits. Choose by which configuration style your team prefers.

Do I still need a .venv for development?

Yes — nox environments are for checking, not for editing with your IDE's autocompletion. Keep uv sync for your everyday environment and use nox before pushing.

How do I test against a Python version uv cannot download?

Point nox at an interpreter already on your system by listing it by path or name; uv uses installed interpreters before downloading. Unusual builds — a distribution's patched Python, PyPy — can be listed the same way.

Can sessions share an environment to save time?

They can with venv_backend="none" or by reusing environments, but that gives up the isolation that makes nox results trustworthy. Use -R for fast reruns instead.