Let's dive in advanced Python automation with nox
, uv
, and GitHub Actions — Part 5 of the Terminus Series
Building on Part 4, where we introduced fast, modern MLOps pipelines using uv
, Docker, and CI/CD, this fifth installment focuses on automating your developer workflow using Nox — and why in 2025 it still reigns as one of the most flexible, transparent tools for Python-based projects.
This article walks through:
- ✅ What exactly Nox is and why it's still relevant in 2025
- 🧰 How we configure our noxfile in Terminus
- 🚀 How GitHub Actions can dynamically discover and run your nox sessions
- ⚖️ A brief but robust comparison of
nox
,Poe the Poet
, anduv
CLI - 🏗️ Why Nox, despite the rise of CLI runners, still plays a crucial orchestration role
Absolutely! Here's a more comprehensive and pedantic rewrite of the section "Going Deeper with uv CLI Tools", structured to clearly guide the reader, explain motivations and use cases, and gracefully transition into the next section on nox
:
Going Deeper with uv
CLI Tools — Beyond uv sync
Up to this point, we've mostly used uv
as a fast, lockfile-aware dependency resolver — replacing pip
, pip-tools
, or poetry
for installing packages via uv sync
. But that’s only scratching the surface.
In practice, uv
is becoming a holistic developer toolchain manager — and in projects like Terminus, it eliminates the need for make
, bash
, or even task runners like poethepoet
in many cases.
Let’s explore the full potential of uv
as a modern CLI toolchain — both for local development and CI/CD pipelines.
uv tool install
and uv tool run
: Global CLI without Virtualenv Pain
While uv sync
installs project-scoped dependencies into virtual environments, uv tool
manages CLI tools globally, with user isolation:
uv tool install nox
uv tool run nox -- -s lint
This:
- Installs
nox
(or any CLI tool) into a dedicated global environment at~/.local/share/uv/tools
- Keeps these tools separate from your project env or system Python
- Ensures reproducibility across developer machines and CI — version-pinned via
uv.lock
if needed -
Avoids
venv
activation, making developer onboarding zero-friction
In CI (as in Terminus), this means we don't need to activate a venv, use pipx
, or worry about system-wide pip
issues. We simply run:
uv tool install nox
uv tool run nox -- -s lint
Caching CLI Tools Between CI Runs
One performance advantage of uv tool
is that CLI tools are cached globally in:
~/.local/share/uv/tools
By caching this directory in GitHub Actions:
- uses: actions/cache@v4
with:
path: ~/.local/share/uv/tools
key: uv-tools-${{ runner.os }}-${{ hashFiles('pyproject.toml', '**/uv.lock') }}
You:
- Avoid repeated downloads or rebuilds of CLI tools (e.g.,
nox
,ruff
,pytest
) - Keep your build minimal and declarative — no surprise installs during jobs
- Ensure clarity between “project dependencies” (
uv sync
) and “development tools” (uv tool
)
Defining CLI Commands with [tool.uv.cli]
in pyproject.toml
Just like poethepoet
, uv
lets you define custom developer commands directly in pyproject.toml
— no extra dependencies needed.
[tool.uv.cli]
lint = "ruff check src"
format = "ruff format src"
test = "pytest tests/"
Run them with:
uv run lint
uv run test
This gives you the ergonomics of a task runner (like make
or poe
) without adding a new config language or runtime dependency.
You can think of it as task aliases bound to your dependency context, all managed by uv
.
Recommendations:
- Use
uv run
or[tool.uv.cli]
for simple local dev flows:lint
,serve
,test
, etc. - Use
poethepoet
if you need cross-platformmake
-like chaining with config inpyproject.toml
- Use
nox
if you need session orchestration, multi-version testing, and dynamic CI compatibility
Now that we understand how uv
and uv tool
fit into modern Python projects, let’s look at how we use nox
in Terminus to automate linting, testing, and Docker builds — in both local and CI pipelines.
Let’s dive into nox
and dynamic CI automation.
What is nox
, and Why Use It?
Nox is a Python automation tool designed to run sessions — isolated environments where you install dependencies and run scripts.
Unlike make
, poe
, or raw shell
scripts:
- Each session is run in a fresh virtual environment (by default)
- You can target multiple Python versions per session
- Dependencies can be scoped using a tool like
uv
,pip
, orpoetry
- It’s Python-native: no new DSL, write Python functions
In Terminus, we use nox
as the automation layer for tasks like linting and testing and can be extended to build the docs, clean up etc.
This makes Nox ideal for CI workflows — especially when the definition of what to run changes over time (via dynamic session discovery).
Terminus noxfile.py — Dissection
Let’s look at how Terminus’ noxfile.py
enforces:
- Single source of truth for Python version (extracted from
pyproject.toml
) - Group-based dependency installation via
uv
- Dev-friendly modular automation
# Parses Python version from pyproject.toml
# Sets uv as the backend
# Defines two nox sessions: lint
@nox.session(python=PYTHON_VERSION)
def lint(session):
"""Run Ruff linter and formatter checks."""
session.run(
"uv", "sync", "--group", LINT_GROUP,
f"--python={session.python}",
env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location},
external=True
)
session.run("ruff", "check", CODE_DIR)
session.run("ruff", "format", "--check", CODE_DIR)
Key takeaways:
- Uses
uv
as venv backend for speed and lockfile support. - Reads the required Python version directly from
pyproject.toml
for full alignment across local + CI workflows. - Dependency isolation: installs only the
lint
group, not the full app stack.
Dynamic Matrix CI with GitHub Actions + Nox
Instead of hardcoding jobs, Terminus uses a modern matrix approach:
Step 1: Determine Python version and available nox sessions
jobs:
generate-matrix:
outputs:
sessions: ${{ steps.set-matrix.outputs.sessions }}
python-version: ${{ steps.get-python-version.outputs.version }}
- Reads the Python version from
pyproject.toml
- Extracts nox sessions via
nox --json -l
and filters withjq
All this happens before the test matrix runs, so any added sessions automatically trigger in CI without manual YAML changes.
Step 2: Run each session in parallel
jobs:
run-nox-sessions:
strategy:
matrix:
session: ${{ fromJson(needs.generate-matrix.outputs.sessions) }}
steps:
- run: nox -s "${{ matrix.session }}"
Each session is isolated, fast, and reproducible — and uses uv
as the environment and dependency manager.
Side-by-side Comparison: nox
vs poe
vs uv cli
Feature | nox |
poethepoet |
uv CLI |
---|---|---|---|
Language | Python-native | Custom pyproject syntax |
Built-in to uv
|
Virtual Envs | Yes (isolated per session) | No (uses current env) | Optional via uv venv
|
CLI Task Syntax | nox -s lint |
poe lint |
uv run ruff check src |
CI Friendly | ✅ Matrix + Python version | ⚠️ Mostly local convenience | ✅ (if you know the commands) |
Session Scoping | Per-group / per-version | Globally defined | Command-line scoped |
Test Matrix Discovery | ✅ (--json ) |
❌ | ❌ |
Modern Lock Support | ✅ via uv sync
|
✅ via uv if integrated |
✅ |
So when to use each?
- Use
poe
oruv cli
for small, script-oriented workflows and fast local CLI access. - Use
nox
when you:- Need multiple Python versions
- Want true isolation per task
- Want to share automation across dev + CI reliably
Full Automation: From Code to Container
Once lint and test sessions pass, our CI continues to:
- Build the image (multi-stage Docker build)
- Authenticate to Docker Hub
- Push image tagged by short SHA +
latest
Defined here: .github/workflows/ci.yml
Final Thoughts
While new tools like uv
and poethepoet
offer impressive speed and simplicity, Nox remains an unparalleled orchestration layer in 2025 for teams needing:
- Python version agility
- Lockfile-aware sessions
- Matrix-based CI pipelines
In Terminus, combining Nox with uv
, GitHub Actions, and Docker
gives us a maintainable and fully automated pipeline — fast, reproducible, and clean.
Codebase
Includes:
noxfile.py
-
Dockerfile
anddocker-compose.yml
- Full GitHub Actions workflow (
ci.yml
) - Example
.env
,src/
, andtests/
(TODO)