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.tomlsupport, 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.whlPrefer 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 publishUse 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.