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.lockcommitted and adevdependency 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.
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
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
-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:
docsbuilds 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.completionregenerates bundled shell-completion scripts for bash, zsh and fish, so they never go stale relative to the commands.release_checkbuilds the sdist and wheel, runstwine check, and verifies that the version matches the changelog's newest entry before anyone pushes a tag.benchtimesmytool --helpwithhyperfineor 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 noxshould run what a contributor needs before pushing — usually tests and lint — without the slow sessions. - Name sessions for their purpose.
tests_lowest_depsandsmokeexplain themselves in--list; docstrings appear there too. - Pass arguments through.
session.posargslets people run a single test file on every Python version without editing anything. - Document it in one line. "Run
uvx noxbefore pushing" inCONTRIBUTING.mdreplaces 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.