Python Build Automation: nox, tox, uv, Hatch and CI
September 16, 2026

Python build automation is the set of commands that take a project from "code on my laptop" to "tested wheel on PyPI" without anyone typing them by hand. For most projects in 2026, the setup that works is small:
pyproject.tomlwith a build backend (hatchling or setuptools) describes the package.- One tool manages environments and dependencies — usually uv, Hatch, or Poetry.
- One task runner holds the named jobs — nox, tox, or a
justfile— so "run the tests" is the same command everywhere. - pre-commit catches lint issues before a commit exists.
- GitHub Actions runs the same tasks on every push and publishes on a tag.
The mistake people make is picking five overlapping tools. You need one of each job, not one of each brand. Below: what each tool actually does, a quick comparison, a working noxfile.py, and a CI workflow you can adapt.
If you're after scripts that automate other work — renaming files, pulling APIs — that's a different topic, covered in Python for automation. This article is about automating the project itself.
Quick Comparison
| Tool | Main job | Config lives in | Runs named tasks | Builds / publishes | Best fit |
|---|---|---|---|---|---|
| nox | Task runner with per-session virtualenvs | noxfile.py (Python) | Yes | Via commands you write | Multi-Python test matrices, logic in Python |
| tox | Test environment manager | tox.toml | Yes | Via commands you write | Libraries with large version/dependency matrices |
| invoke | General task runner | tasks.py (Python) | Yes | Via commands you write | Ops-style scripts, no env management needed |
| Make / just | Command runner | Makefile / justfile | Yes | Via commands you write | Polyglot repos, thin wrappers over other tools |
| uv | Package, env and Python manager | pyproject.toml + uv.lock | No | uv build, uv publish | Fast default for new projects |
| Hatch | Project manager + env scripts | pyproject.toml | Yes (env scripts) | hatch build, hatch publish | One tool for envs, tests, build, release |
| Poetry | Dependency manager + packager | pyproject.toml + poetry.lock | No | poetry build, poetry publish | Teams already standardized on it |
| pre-commit | Git hook manager | .pre-commit-config.yaml | Hooks only | No | Lint and format before commit |
| GitHub Actions | CI/CD runner | .github/workflows/*.yml | Yes (jobs) | Yes, via steps | Running all of the above on every push |
Read the "Runs named tasks" column carefully. uv and Poetry manage environments and packaging, but neither CLI has a command for defining your own named tasks. That's why they're so often paired with nox or just.
How a Python build works: pyproject.toml and build backends
Every modern Python build starts in pyproject.toml. Three standards do the heavy lifting:
- PEP 517 splits the work between a build frontend (the tool you run, like
python -m build,uv build, or pip) and a build backend (the library that actually produces the sdist and wheel, through hooks such asbuild_wheelandbuild_sdist). - PEP 518 adds the
[build-system]table, which tells the frontend which backend to install. - PEP 621 standardizes the
[project]table — name, version, dependencies — so metadata isn't locked to one tool.
A fourth, PEP 735, defines [dependency-groups] for development-only dependencies like pytest and ruff. uv, Hatch and nox all read it, which means you can declare your dev tools once.
A minimal project looks like this:
[build-system]
requires = ["hatchling >= 1.26"]
build-backend = "hatchling.build"
[project]
name = "acme-tools"
version = "0.3.0"
requires-python = ">=3.10"
dependencies = ["httpx"]
[dependency-groups]
dev = ["pytest", "ruff"]
The Python Packaging User Guide lists the common backends side by side:
| Backend | build-backend value | Typical reason to choose it |
|---|---|---|
| hatchling | hatchling.build | Clean defaults, pairs with Hatch |
| setuptools | setuptools.build_meta | C extensions, long-lived projects |
| flit-core | flit_core.buildapi | Pure-Python, minimal config |
| pdm-backend | pdm.backend | Projects using PDM |
| uv_build | uv_build | Pure-Python projects on uv |
| poetry-core | poetry.core.masonry.api | Projects using Poetry |
The practical point: the backend is swappable. Because the interface is standard, any frontend can build your package without knowing which backend you picked.
Task runners: nox, tox, invoke, Make and just
A task runner answers one question: what does "run the tests" mean for this repo? Without one, the answer lives in a stale README or in CI YAML nobody runs locally.
nox: sessions written in Python
nox defines each task as a decorated Python function called a session. Each session gets its own virtualenv, and passing python=[...] creates one session per interpreter. Since sessions are plain Python, conditionals and loops need no special syntax.
Here's a realistic noxfile.py for the project above:
import nox
# Use uv to create environments when it's installed, virtualenv otherwise.
nox.options.default_venv_backend = "uv|virtualenv"
nox.options.sessions = ["lint", "tests"]
PYPROJECT = nox.project.load_toml("pyproject.toml")
DEV = nox.project.dependency_groups(PYPROJECT, "dev")
@nox.session
def lint(session):
"""Check style and formatting with ruff."""
session.install("ruff")
session.run("ruff", "check", ".")
session.run("ruff", "format", "--check", ".")
@nox.session(python=["3.12", "3.13"])
def tests(session):
"""Run the test suite on each supported Python."""
session.install(".", *DEV)
session.run("pytest", *session.posargs)
@nox.session(default=False)
def build(session):
"""Build the sdist and wheel into dist/."""
session.install("build")
session.run("python", "-m", "build")
The commands you'll use daily:
noxruns the default sessions (lint and both test versions)nox -s testsruns one session;nox -s buildruns the one excluded bydefault=Falsenox -s tests -- -k parserforwards arguments to pytest viasession.posargsnox -llists sessions with their docstringsnox -Rreuses existing environments and skips reinstalling, for fast reruns
Nox also has opt-in parallel execution. Sessions that declare allow_parallel=True can run at the same time under --parallel.
tox: declarative environment matrices
tox solves the same problem declaratively. Its docs now recommend tox.toml and describe the INI format as deprecated: existing projects keep working, but INI gets no new features. You list environments in env_list, define shared settings under [env_run_base], and override per environment under [env.lint].
Common commands are tox (run the default list), tox run -e lint, tox run -e 3.13 -- -v to pass arguments through, and tox p to run environments in parallel. If you want uv speed without leaving tox, the tox-uv plugin replaces virtualenv and pip with uv inside tox environments.
nox or tox? Pick tox when your matrix is mostly data: Python versions crossed with dependency versions. Pick nox when a task needs real logic, like reading a file, branching on the platform, or chaining steps.
invoke, Make and just: runners without environments
These three run commands but don't create virtualenvs for you.
- Invoke reads a
tasks.pyfile of@taskfunctions that callc.run("..."). Function arguments become CLI flags automatically,invoke --listshows what's available, and@task(pre=[clean])runs a dependency first. - Make is already on most Unix-like developer machines, and a
Makefilewithtest,lintandbuildtargets is still a perfectly good front door. The friction is Make-isms like.PHONYand tab-sensitive syntax. - just describes itself as a command runner, not a build system. Recipes live in a
justfile, take arguments, need no.PHONY, and just runs on Linux, macOS and Windows with no extra dependencies.
A common modern pattern: a thin justfile whose recipes are one-liners like uv run pytest. Environment handling stays in uv, and the justfile only gives people short, memorable names.
uv, Hatch and Poetry: one tool for the whole loop
These tools manage the project itself: dependencies, lockfiles, environments, and packaging.
uv: sync, run, build, publish
uv covers most of the loop with four commands:
| Step | Command | What it does |
|---|---|---|
| Install | uv sync --locked | Installs from uv.lock and errors if the lockfile is out of date |
| Run | uv run pytest | Makes sure the project environment is up to date, then runs the command |
| Version | uv version --bump minor | Updates the version in pyproject.toml |
| Build | uv build --no-sources | Writes sdist and wheel to dist/; --no-sources checks the build works without tool.uv.sources, as other build tools would see it |
| Publish | uv publish | Uploads to PyPI; with Trusted Publishing from CI, no token is needed |
uv add --dev pytest writes to the PEP 735 dev group, and uv syncs that group by default. uv doesn't define named tasks, so pair it with nox (set the uv backend, as in the noxfile above) or with just.
Hatch: environments with scripts
Hatch is the closest thing to an all-in-one. Environments are declared under [tool.hatch.envs.<name>] and can include dependency groups. Each environment can carry named scripts:
[tool.hatch.envs.test]
dependency-groups = ["dev"]
[tool.hatch.envs.test.scripts]
cov = "pytest --cov=acme_tools tests"
You run that with hatch run test:cov. Hatch also ships hatch test, which runs pytest (with coverage.py behind --cover) without a custom environment. hatch build produces the sdist and wheel, and hatch publish uploads whatever is in dist/. If your team wants one binary and no separate task runner, Hatch is the strongest case.
Poetry: dependency management and packaging
Poetry handles resolution and locking, and it packages with poetry build (--format wheel or sdist limits the output) and poetry publish --build. Its docs recommend poetry sync over poetry install to avoid stale packages, and since Poetry 2.0 they point new projects at the standard project.dependencies table. Like uv, Poetry has no task command. Teams on Poetry usually add a Makefile, just, or nox for the rest.
Wiring it into pre-commit and GitHub Actions
Local tasks only help if they also run where nobody can skip them.
pre-commit for the fast checks
pre-commit manages Git hooks from a .pre-commit-config.yaml file. Run pre-commit install once per clone and the hooks run on every commit. Run pre-commit run --all-files to check the whole repo (a useful CI step), and pre-commit autoupdate to bump hook versions. Keep it to seconds-long checks like ruff and whitespace fixes. Slow test suites belong in CI.
A GitHub Actions workflow
GitHub Actions runs your tasks on each push. The uv docs recommend the official astral-sh/setup-uv action, which can set the Python version per matrix entry and cache downloads:
name: ci
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.12", "3.13"]
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v9.0.0
with:
python-version: ${{ matrix.python-version }}
enable-cache: true
- run: uv sync --locked
- run: uv run ruff check .
- run: uv run pytest
publish:
if: startsWith(github.ref, 'refs/tags/v')
needs: test
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v9.0.0
- run: uv build --no-sources
- run: uv publish
Three details matter here:
id-token: writeis what enables Trusted Publishing. You register the repo, workflow and environment as a trusted publisher on PyPI, and no API token is ever stored in secrets.- Publishing only runs on version tags and only after tests pass, so a broken build can't reach PyPI.
- Pin actions deliberately. The uv docs pin actions to full commit SHAs. Whatever you choose, check each action's releases page before copying a tag from any guide, including this one.
If you'd rather keep CI thin, replace the three run steps with uvx nox. The workflow then runs exactly what developers run locally. For how GitHub Actions compares with other automation platforms, see Pieces vs GitHub Actions vs n8n.
Which setup to pick
The right stack depends on who touches the repo, not on which tool is newest:
- New pure-Python project: uv + hatchling or uv_build + a small noxfile or justfile + pre-commit + the workflow above.
- Library with a wide compatibility matrix: tox (with tox-uv) or nox with parametrized sessions.
- Team that wants one tool: Hatch, using env scripts and
hatch test. - Existing Poetry project: keep Poetry, and add nox or just for named tasks instead of migrating.
Key points:
- Standards (PEP 517, 518, 621, 735) mean the backend, env manager and task runner are independent choices.
- uv and Poetry don't run named tasks. nox, tox, Hatch scripts, just, Make and invoke do.
- The same command should run locally and in CI. That one rule prevents most "passes on my machine" failures.
- Publish from CI with Trusted Publishing, gated on tags and passing tests.
Build automation is one layer in a bigger stack. The five layers of automation in computer programming shows where it sits next to formatting, deployment and AI code generation.
The same setup problem shows up well beyond Python packaging. If the setup step is where you keep stalling with other people's AI tools, Taku mirrors a working AI setup into a desktop workspace and runs it, so you can use a power user's configuration without reproducing their environment first. Taku is in Beta, and the Mac app is available now.
FAQ
What is Python build automation? It's scripting the repeatable steps of a Python project (installing dependencies, linting, testing, building the sdist and wheel, and publishing) so one command or one CI run does all of them the same way every time.
Is nox or tox better for Python build automation?
Neither is strictly better. tox suits declarative matrices of Python and dependency versions in tox.toml. nox suits tasks that need logic, because sessions are Python functions. Both can use uv under the hood.
Can uv replace nox or tox?
Partly. uv handles environments, locking, uv run, uv build and uv publish, but it has no named-task feature and no built-in multi-session matrix runner. Most uv projects still add nox, tox or a justfile.
Do I still need setup.py?
Not for most projects. A pyproject.toml with a [build-system] table and a [project] table is enough for backends like hatchling or setuptools. Projects with complex C extensions may still keep setuptools configuration.
How do I automate publishing a Python package to PyPI?
Build in CI with uv build, hatch build or python -m build, then upload with uv publish or the PyPA publish action. Use PyPI Trusted Publishing with id-token: write so no long-lived token is stored, and trigger the job on version tags.
What's the difference between Python build automation and Python for automation? Build automation targets your own codebase: tests, packaging, releases. "Python for automation" (sometimes searched as py automation) means using Python scripts to automate other work, like files, spreadsheets and APIs.