pyproject.toml and uv — the modern package layout

Build backend, dependency groups, lockfiles, and the uv commands that replace pip, pipenv, and pip-tools.

🚚 Module 9 9 min read Not started

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

  1. Author a pyproject.toml that's PyPI-ready.
  2. Use uv for installs, sync, lock, run, build.
  3. Pin and reproduce dependency versions.
  4. Configure entry points, build backends, optional deps.
  5. Set up uv workspaces (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:

toml
[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 via uv add 'my-package[dev]' or pip install '...[dev]'.
  • project.scripts: CLI entry points (my-climy_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, maturin for Rust).

3. uv — what every command does

CommandEffect
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 syncinstall exactly what uv.lock says
uv sync --frozenfail if lock is out of date (use in CI)
uv sync --no-devskip dev deps (production install)
uv lockre-resolve and rewrite uv.lock
uv run <cmd>run a command in the project venv
uv treeprint dependency tree
uv pip install ...pip-compatible interface (rarely needed if you use uv add)
uv buildbuild sdist + wheel
uv publishupload to PyPI
uv tool install <pkg>install a tool globally (isolated; like pipx)
uv python install 3.12install a Python version
uv python listlist 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.

powershell
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 one

5. Dependency groups (PEP 735)

Beyond optional-dependencies (which are for users to install), dependency groups are for project-internal tooling:

toml
[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.

BackendNotes
hatchlingDefault in uv init. Pure Python, simple, fast. Use unless you have a reason.
setuptoolsLong-time default; needed for C extensions and complex builds.
flit-coreMinimal, no C support.
pdm-backendIf you use PDM features.
poetry-coreIf you use Poetry-specific things.
maturinRust extensions via PyO3.
scikit-build-coreC/C++ extensions via CMake.
mesonpyC/C++ via Meson.
toml
[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:

toml
[tool.hatch.build.targets.wheel]
packages = ["src/my_package"]

setuptools:

toml
[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

toml
[project.scripts]
my-cli = "my_package.cli:main"

After install, my-cli is on PATH and calls my_package.cli.main().

Plugin entry points

toml
[project.entry-points."myapp.plugins"]
csv = "my_package.plugins.csv:CSVPlugin"

Discoverable by:

python
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

powershell
uv build

Produces:

  • 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

  1. Register on https://pypi.org/.
  2. Enable 2FA.
  3. Create an API token in account settings.
  4. Store in ~/.pypirc or use UV_PUBLISH_TOKEN:
powershell
$env:UV_PUBLISH_TOKEN = "pypi-..."
uv publish

Test on TestPyPI first

powershell
uv publish --publish-url https://test.pypi.org/legacy/ --token testpypi-...
uv pip install --index-url https://test.pypi.org/simple/ my-package

OIDC token from GitHub Actions, no PyPI tokens to store. Configure in PyPI project settings → "Trusted publishers."

GitHub Actions:

yaml
- uses: astral-sh/setup-uv@v3
- run: uv build
- run: uv publish              # uses OIDC; no PYPI_API_TOKEN secret

11. 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:

toml
[tool.uv.workspace]
members = ["packages/*", "apps/*"]

Inside apps/cli/pyproject.toml:

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.00.1.1): bug fix; no API changes.
  • Minor (0.1.00.2.0): new feature; backwards-compatible.
  • Major (0.1.01.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-version or bumpver for tag-driven bumps.
  • hatch-vcs / setuptools-scm to derive version from git tags.

For library users: use __version__ in __init__.py:

python
from importlib.metadata import version
__version__ = version("my-package")

13. CHANGELOG and release process

Keep CHANGELOG.md in Keep a Changelog format. On release:

  1. Update version (pyproject.toml).
  2. Move "Unreleased" → date in CHANGELOG.md.
  3. Commit, tag (git tag v0.2.0), push tag.
  4. CI runs uv build + uv publish on tag.

For automated CHANGELOG and version bumping, use towncrier + commitizen + semantic-release.


14. Reproducibility checklist

Before saying "this app is reproducible":

  • requires-python is pinned.
  • uv.lock committed.
  • CI uses uv sync --frozen.
  • No pip install outside uv add.
  • Docker image uses --frozen and --no-dev for production builds.
  • [tool.ruff], [tool.mypy], [tool.pytest] configured.
  • pre-commit configured.

Hands-on lab (1.5 hours)

  1. Scaffold a new project with uv init. Add 3 deps, 2 dev deps.
  2. Run uv tree; explore the resolved graph.
  3. Add a [project.scripts] entry; install editable; run the CLI.
  4. Build with uv build; inspect the wheel (unzip, list files).
  5. Publish to TestPyPI; install in a fresh venv; verify.
  6. Convert your project to a src/ layout; verify imports still work.
  7. Bonus: set up a workspace with two packages where one depends on the other.

Common pitfalls

  1. Committing requirements.txt instead of (or in addition to) uv.lock — diverges.
  2. pip install ... adding deps without updating pyproject.toml.
  3. Missing [build-system] block (older convention; PEP 518 requires it).
  4. Using top-level package (no src/) and not noticing import shadowing.
  5. Publishing without testing on TestPyPI first.
  6. Bumping major version when you mean minor — breaks downstream users.
  7. Skipping --frozen in CI; "works on my machine" returns.

Self-check

  1. What does uv.lock contain?
  2. Difference between dependencies and optional-dependencies?
  3. What's a build backend?
  4. When use a workspace?
  5. What's "Trusted Publishing"?

References

Sign in to save your progress and earn badges.