pyproject.toml and uv — the modern package layout
Build backend, dependency groups, lockfiles, and the uv commands that replace pip, pipenv, and pip-tools.
Why this matters
Modern Python packaging in 2026 means one file (pyproject.toml) and one tool (uv). No more setup.py, no more requirements.txt, no more confusion between pip, pipenv, poetry, pdm, conda. This lesson installs the muscle memory for managing dependencies, building distributions, and pinning environments reproducibly.
Learning objectives
- Author a
pyproject.tomlthat's PyPI-ready. - Use
uvfor installs, sync, lock, run, build. - Pin and reproduce dependency versions.
- Configure entry points, build backends, optional deps.
- Set up
uvworkspaces (mono-repo of multiple packages).
1. pyproject.toml — the single source of truth
PEP 621 standardised project metadata in pyproject.toml. Every modern tool reads it.
Minimal example:
[project]
name = "my-package"
version = "0.1.0"
description = "What it does in one sentence."
readme = "README.md"
authors = [{ name = "Ada Lovelace", email = "ada@example.com" }]
license = { text = "MIT" }
requires-python = ">=3.12"
dependencies = [
"httpx>=0.27",
"pydantic>=2.7",
"typer>=0.12",
]
[project.optional-dependencies]
dev = ["pytest>=8", "ruff>=0.7", "mypy>=1.13"]
docs = ["mkdocs-material>=9"]
[project.urls]
Homepage = "https://github.com/me/my-package"
Issues = "https://github.com/me/my-package/issues"
[project.scripts]
my-cli = "my_package.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"That's a complete, modern, PyPI-publishable Python project metadata block.
2. Important fields
requires-python: minimum Python. Honour it strictly; tools refuse incompatible installs.dependencies: runtime requirements with PEP 508 specifiers (pkg>=1.0,<2.0).optional-dependencies: extras. Installed viauv add 'my-package[dev]'orpip install '...[dev]'.project.scripts: CLI entry points (my-cli→my_package.cli:main).project.gui-scripts: GUI entry points (Windows binds these differently).project.entry-points: arbitrary plugin entry points (used by plugin systems — e.g.,pytest's plugin discovery).build-system: which build backend (hatchling,setuptools,pdm-backend,flit-core,maturinfor Rust).
3. uv — what every command does
| Command | Effect |
|---|---|
uv init <name> | scaffold a project |
uv add <pkg> | add a dep to pyproject.toml, update uv.lock, install |
uv add --dev <pkg> | add to dev dependency group |
uv add --optional docs <pkg> | add to an optional extra |
uv remove <pkg> | remove dep |
uv sync | install exactly what uv.lock says |
uv sync --frozen | fail if lock is out of date (use in CI) |
uv sync --no-dev | skip dev deps (production install) |
uv lock | re-resolve and rewrite uv.lock |
uv run <cmd> | run a command in the project venv |
uv tree | print dependency tree |
uv pip install ... | pip-compatible interface (rarely needed if you use uv add) |
uv build | build sdist + wheel |
uv publish | upload to PyPI |
uv tool install <pkg> | install a tool globally (isolated; like pipx) |
uv python install 3.12 | install a Python version |
uv python list | list installed/managed Pythons |
uv keeps the lockfile (uv.lock) up to date with every dep change. Commit it.
4. Lockfiles and reproducibility
uv.lock contains the exact resolved versions of every package and its transitive deps, plus per-platform wheels and hashes. Two developers running uv sync get identical environments down to the hash.
This is the killer feature over pip install -r requirements.txt, which only locks if you maintain a separate pip-tools-style pinned file.
uv sync # installs from uv.lock; updates lock if pyproject changed
uv sync --frozen # CI mode: fails if lock out of sync
uv lock --upgrade # bump all deps to latest within constraints
uv lock --upgrade-package httpx # bump just one5. Dependency groups (PEP 735)
Beyond optional-dependencies (which are for users to install), dependency groups are for project-internal tooling:
[dependency-groups]
dev = [
"pytest>=8",
"ruff>=0.7",
"mypy>=1.13",
"pre-commit>=3",
]
docs = ["mkdocs-material>=9"]
ci = ["coverage[toml]"]uv add --dev writes here. Activate with uv sync --group dev (default).
Groups never ship to PyPI; they're for uv / pdm to manage local dev installs.
6. Build backends
Pick one in [build-system]. All work; pick by features.
| Backend | Notes |
|---|---|
hatchling | Default in uv init. Pure Python, simple, fast. Use unless you have a reason. |
setuptools | Long-time default; needed for C extensions and complex builds. |
flit-core | Minimal, no C support. |
pdm-backend | If you use PDM features. |
poetry-core | If you use Poetry-specific things. |
maturin | Rust extensions via PyO3. |
scikit-build-core | C/C++ extensions via CMake. |
mesonpy | C/C++ via Meson. |
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/my_package"]7. src/ layout (recap from Lesson 0.3)
my-package/
├── pyproject.toml
├── README.md
├── src/
│ └── my_package/
│ ├── __init__.py
│ ├── cli.py
│ └── core.py
└── tests/In pyproject.toml:
[tool.hatch.build.targets.wheel]
packages = ["src/my_package"]setuptools:
[tool.setuptools.packages.find]
where = ["src"]The src/ layout forces installation (uv pip install -e .) before importing your package — catches "works in repo, breaks on install" issues. Recommended.
8. Entry points — CLIs and plugins
Console scripts
[project.scripts]
my-cli = "my_package.cli:main"After install, my-cli is on PATH and calls my_package.cli.main().
Plugin entry points
[project.entry-points."myapp.plugins"]
csv = "my_package.plugins.csv:CSVPlugin"Discoverable by:
from importlib.metadata import entry_points
for ep in entry_points(group="myapp.plugins"):
plugin = ep.load()(...)This is how pytest, flake8, pluggy-based apps discover third-party plugins.
9. Building distributions
uv buildProduces:
dist/my_package-0.1.0.tar.gz(sdist — source distribution)dist/my_package-0.1.0-py3-none-any.whl(wheel — pre-built)
Wheels install faster (no setup.py execution). Always upload both: wheels for users, sdist as fallback.
10. Publishing to PyPI
One-time setup
- Register on https://pypi.org/.
- Enable 2FA.
- Create an API token in account settings.
- Store in
~/.pypircor useUV_PUBLISH_TOKEN:
$env:UV_PUBLISH_TOKEN = "pypi-..."
uv publishTest on TestPyPI first
uv publish --publish-url https://test.pypi.org/legacy/ --token testpypi-...
uv pip install --index-url https://test.pypi.org/simple/ my-packageTrusted Publishing (recommended in 2026)
OIDC token from GitHub Actions, no PyPI tokens to store. Configure in PyPI project settings → "Trusted publishers."
GitHub Actions:
- uses: astral-sh/setup-uv@v3
- run: uv build
- run: uv publish # uses OIDC; no PYPI_API_TOKEN secret11. Workspaces — monorepos with multiple packages
uv supports workspaces (PEP 735-ish, like Cargo / npm):
my-monorepo/
├── pyproject.toml (workspace root)
├── uv.lock
├── packages/
│ ├── core/
│ │ └── pyproject.toml (package 1)
│ └── api/
│ └── pyproject.toml (package 2; depends on core)
└── apps/
└── cli/
└── pyproject.toml (package 3)Root pyproject.toml:
[tool.uv.workspace]
members = ["packages/*", "apps/*"]Inside apps/cli/pyproject.toml:
[project]
name = "cli"
dependencies = ["core"] # editable link to workspace package
[tool.uv.sources]
core = { workspace = true }uv sync in the root sets up all packages, installs editable links between them. Useful for splitting code into reusable libs while developing together.
12. Versioning
Semantic versioning (major.minor.patch):
- Patch (
0.1.0→0.1.1): bug fix; no API changes. - Minor (
0.1.0→0.2.0): new feature; backwards-compatible. - Major (
0.1.0→1.0.0): breaking change.
For pre-1.0.0, treat minor bumps as potentially breaking — convention varies.
Automate with:
hatch version(if using hatchling).bump-my-versionorbumpverfor tag-driven bumps.hatch-vcs/setuptools-scmto derive version from git tags.
For library users: use __version__ in __init__.py:
from importlib.metadata import version
__version__ = version("my-package")13. CHANGELOG and release process
Keep CHANGELOG.md in Keep a Changelog format. On release:
- Update version (
pyproject.toml). - Move "Unreleased" → date in
CHANGELOG.md. - Commit, tag (
git tag v0.2.0), push tag. - CI runs
uv build+uv publishon tag.
For automated CHANGELOG and version bumping, use towncrier + commitizen + semantic-release.
14. Reproducibility checklist
Before saying "this app is reproducible":
-
requires-pythonis pinned. -
uv.lockcommitted. - CI uses
uv sync --frozen. - No
pip installoutsideuv add. - Docker image uses
--frozenand--no-devfor production builds. -
[tool.ruff],[tool.mypy],[tool.pytest]configured. -
pre-commitconfigured.
Hands-on lab (1.5 hours)
- Scaffold a new project with
uv init. Add 3 deps, 2 dev deps. - Run
uv tree; explore the resolved graph. - Add a
[project.scripts]entry; install editable; run the CLI. - Build with
uv build; inspect the wheel (unzip, list files). - Publish to TestPyPI; install in a fresh venv; verify.
- Convert your project to a
src/layout; verify imports still work. - Bonus: set up a workspace with two packages where one depends on the other.
Common pitfalls
- Committing
requirements.txtinstead of (or in addition to)uv.lock— diverges. pip install ...adding deps without updatingpyproject.toml.- Missing
[build-system]block (older convention; PEP 518 requires it). - Using top-level package (no
src/) and not noticing import shadowing. - Publishing without testing on TestPyPI first.
- Bumping major version when you mean minor — breaks downstream users.
- Skipping
--frozenin CI; "works on my machine" returns.
Self-check
- What does
uv.lockcontain? - Difference between
dependenciesandoptional-dependencies? - What's a build backend?
- When use a workspace?
- What's "Trusted Publishing"?
References
- PEP 621 — Storing project metadata in pyproject.toml.
- PEP 631, 660, 735.
uvdocs: https://docs.astral.sh/uv/.- Hatchling docs: https://hatch.pypa.io/latest/.
- Python Packaging User Guide: https://packaging.python.org/.
- "Trusted Publishing" docs on PyPI.
Sign in to save your progress and earn badges.