Packaging with pyproject.toml

Python · Advanced · 14 min

Sharing code — internally or on PyPI — and installing your own project reliably requires proper packaging.

A modern Python project is described by one file, pyproject.toml. Its [build-system] table names the build backend (hatchling, setuptools, flit-core, pdm-backend, uv_build...) that turns your source into installable artifacts. The [project] table holds standard metadata (PEP 621): name, version, requires-python, dependencies, optional dependency groups, and entry points such as console scripts.

The recommended src layout puts your package at src/yourpkg/. It forces tests to run against the installed package rather than whatever happens to be in the current directory, catching missing-file bugs before your users do.

Development workflow:

  • pip install -e . makes an editable install: the venv points at your source, so changes take effect without reinstalling
  • python -m build (from the build package) creates an sdist (.tar.gz of the source) and a wheel (.whl, a ready-to-install archive) in dist/
  • twine upload dist/* publishes to PyPI (use TestPyPI first, and prefer trusted publishing from CI)

Dependency specifiers in dependencies should be ranges for libraries ("httpx>=0.27"), so your package can coexist with others. Exact pins belong in an application's lock file, not in a library's metadata.

[project.scripts] generates command-line executables on install: mytool = "mytool.cli:main" creates a mytool command that calls main(). Tools also read their configuration from the same file ([tool.pytest.ini_options], [tool.ruff], [tool.mypy]).

uv, Poetry, PDM and Hatch wrap these steps into one CLI, but all of them produce the same standard pyproject metadata and wheels.

Example

# pyproject.toml          layout: src/greetkit/__init__.py, src/greetkit/cli.py
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "greetkit"
version = "0.3.0"
description = "Friendly greetings for the command line"
readme = "README.md"
requires-python = ">=3.11"
dependencies = ["rich>=13"]

[project.optional-dependencies]
dev = ["pytest>=8", "mypy"]

[project.scripts]
greet = "greetkit.cli:main"

[tool.pytest.ini_options]
testpaths = ["tests"]

# pip install -e ".[dev]"   ->  editable install with dev extras
# python -m build           ->  dist/greetkit-0.3.0-py3-none-any.whl

Remember

  • pyproject.toml declares the build backend and [project] metadata
  • Use a src layout and pip install -e . during development
  • Libraries declare version ranges; applications pin exact versions in lock files

Watch out for

  • Pinning exact dependency versions in a library, causing conflicts for its users.

Check yourself

  1. What does `[project.scripts] mytool = "mytool.cli:main"` do?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/distributing/index.html