Code of the Day
AdvancedPublishing to PyPI

Build backends

Write a complete pyproject.toml using hatchling and produce a wheel with python -m build.

UtilitiesAdvanced8 min read
Recommended first
By the end of this lesson you will be able to:
  • Write a pyproject.toml using hatchling as the build backend
  • Declare project metadata including name, version, dependencies, and requires-python
  • Register a console script entry point
  • Build a wheel and sdist with python -m build

A pyproject.toml is the single configuration file that describes your package to the build system, to pip, and to development tools. Getting this file right is the difference between a package that installs cleanly everywhere and one that only works on your laptop.

Choosing hatchling

PEP 517 decoupled the build frontend (python -m build) from the build backend (the library that does the actual work). Several backends exist: setuptools, flit, pdm-backend, and hatchling. This module uses hatchling because it requires minimal configuration for pure-Python projects, has an active maintenance team, and is the default backend for new Hatch projects.

A complete pyproject.toml

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "my-tool"
version = "0.1.0"
description = "A CLI utility for processing text files."
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
authors = [
  { name = "Your Name", email = "you@example.com" },
]
keywords = ["cli", "text-processing"]
classifiers = [
  "Programming Language :: Python :: 3",
  "License :: OSI Approved :: MIT License",
  "Environment :: Console",
]
dependencies = [
  "click>=8.1",
  "rich>=13.0",
]

[project.scripts]
my-tool = "my_tool.cli:main"

[project.urls]
Homepage = "https://github.com/you/my-tool"

Each section does specific work:

  • [build-system] tells pip and python -m build which backend to call.
  • [project] is the canonical metadata — this is what PyPI displays and what pip show prints.
  • requires-python is enforced at install time; pip will refuse to install on an older interpreter.
  • dependencies are pinned with >= lower bounds, not == — let users decide the upper bound unless you have a known incompatibility.
  • [project.scripts] maps the my-tool command to the main function in my_tool/cli.py. This is what creates the executable when the package is installed.

Project layout

Hatchling expects your source code in either src/my_tool/ (the src layout) or my_tool/ at the root. The src layout is preferred because it prevents accidental imports of the un-installed package during testing:

my-tool/
├── pyproject.toml
├── README.md
├── src/
│   └── my_tool/
│       ├── __init__.py
│       └── cli.py
└── tests/
    └── test_cli.py

Tell hatchling to look in src/ by adding one line:

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

Building

Install the build frontend once:

pip install build

Then build both artifacts from the project root:

python -m build

This produces two files in dist/:

dist/
├── my_tool-0.1.0-py3-none-any.whl
└── my_tool-0.1.0.tar.gz

The wheel is what pip will prefer when installing. The sdist is the fallback.

Run twine check dist/* immediately after building. It validates your metadata against PyPI's requirements and catches problems before upload — missing descriptions, malformed classifiers, and similar issues that would cause a rejected upload.

Where to go next

Next: versioning — applying SemVer, exposing __version__ from importlib.metadata, and understanding pre-release suffixes.

Finished reading? Mark it complete to track your progress.

On this page