Overview

A Python package is defined by its pyproject.toml. No setup.py, no MANIFEST.in, no requirements.txt at the package root. This page covers the standard layout, backend choices, and the publishing pipeline. For day-to-day dependency and environment management, see python-dependency-management.

pyproject.toml is the single source of truth

All project metadata, build configuration, tool settings, and dependency lists live in pyproject.toml. The one-source-of-truth rule from config-files applies here.

[project]
name = "mypackage"
version = "0.3.1"
description = "Short sentence about what this does."
requires-python = ">=3.12"
license = { text = "MIT" }
dependencies = [
  "httpx>=0.27",
  "pydantic>=2.7",
]
 
[project.optional-dependencies]
dev = ["pytest>=8", "ruff>=0.5", "pyright>=1.1.370"]
test = ["pytest>=8", "pytest-asyncio>=0.23", "hypothesis>=6"]

Never duplicate dependency lists. CI installs .[dev] or .[test]; production images install bare ..

Choose a build backend and stick to it

The build backend compiles your pyproject.toml into a wheel and sdist. Pick one at project start; do not mix backends in the same repo.

  • Hatch: the current recommended default. First-class pyproject.toml support, built-in env management, and versioning from VCS tags.
  • setuptools: the fallback for complex C extensions or legacy codebases.
  • uv: manages builds and installs; delegates to the declared backend for the actual wheel build.
  • Poetry: avoid on new projects unless the team already standardizes on it; its PEP 517 compliance has historically lagged.
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

Use a src layout

Place all importable code under src/<packagename>/. This layout prevents accidental imports of the development tree during pytest and forces a real install for tests to pass.

mypackage/
├── src/
│   └── mypackage/
│       ├── __init__.py
│       └── core.py
├── tests/
│   └── test_core.py
├── pyproject.toml
└── README.md

See project-structure for the broader directory layout rules that apply across languages.

Declare entry points for CLI tools

[project.scripts] maps command names to Python callables. The build backend installs these as executable scripts when the package is installed.

[project.scripts]
myapp = "mypackage.cli:main"
myapp-worker = "mypackage.worker:run"

The function referenced (mypackage.cli:main) must be importable and must accept no required arguments when called from the shell. Use argparse or click for argument parsing inside the function.

Build wheels with uv build

uv build produces an sdist and a wheel in dist/. It respects the declared build backend.

uv build
# produces:
# dist/mypackage-0.3.1.tar.gz
# dist/mypackage-0.3.1-py3-none-any.whl

Prefer pure-Python wheels (py3-none-any) when possible. C extension packages need platform-specific wheels built in CI via cibuildwheel for each target OS and architecture.

Publish to PyPI with uv publish

uv publish uploads dist/* to PyPI using credentials from the environment.

export UV_PUBLISH_TOKEN="pypi-..."
uv publish

Use UV_PUBLISH_TOKEN (a PyPI API token scoped to the project) rather than username and password. Generate the token at pypi.org under account settings and store it as a CI secret. Never commit tokens to the repo.

For the publishing workflow in CI:

- name: Build
  run: uv build
- name: Publish
  run: uv publish
  env:
    UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}

Version from VCS tags, not source files

Set version dynamically from the Git tag so the pyproject.toml and the release tag can never diverge.

[tool.hatch.version]
source = "vcs"
 
[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"

Tag releases as v0.3.1. The build system strips the v prefix when writing the wheel version.