Python packaging used to be confusing. It's simpler now. Here's the modern approach.
The Standard: pyproject.toml
One file for all metadata:
[project]
name = "mypackage"
version = "1.0.0"
description = "My awesome package"
readme = "README.md"
requires-python = ">=3.11"
license = {text = "MIT"}
authors = [{name = "Owen", email = "owen@example.com"}]
dependencies = [
"requests>=2.28",
"click>=8.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"ruff>=0.4",
"mypy>=1.10",
]
[project.scripts]
mycommand = "mypackage.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"No setup.py. No setup.cfg. No requirements.txt. Just this.
Build Backends
The [build-system] section specifies how to build your package.
Hatchling (recommended)
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"Simple, fast, minimal config.
Setuptools
[build-system]
requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta"The classic. Still works fine.
Poetry
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"If you use Poetry for dependency management.
Project Structure
mypackage/
├── src/
│ └── mypackage/
│ ├── __init__.py
│ └── main.py
├── tests/
│ └── test_main.py
├── pyproject.toml
└── README.md
The src/ layout prevents accidental imports from the project root.
Installing for Development
# Create venv
python -m venv .venv
source .venv/bin/activate
# Install in editable mode with dev deps
pip install -e ".[dev]"Editable mode (-e) means changes to source are immediately available.
Building
# Install build tool
pip install build
# Build sdist and wheel
python -m build
# Output in dist/
ls dist/
# mypackage-1.0.0.tar.gz
# mypackage-1.0.0-py3-none-any.whlPublishing
# Install twine
pip install twine
# Upload to PyPI
twine upload dist/*
# Or test PyPI first
twine upload --repository testpypi dist/*Version Management
Static version
[project]
version = "1.0.0"Dynamic version from file
[project]
dynamic = ["version"]
[tool.hatch.version]
path = "src/mypackage/__init__.py"# src/mypackage/__init__.py
__version__ = "1.0.0"Dynamic version from git
[project]
dynamic = ["version"]
[tool.hatch.version]
source = "vcs"Entry Points
CLI commands
[project.scripts]
mycommand = "mypackage.cli:main"After install: mycommand runs mypackage.cli.main().
GUI commands
[project.gui-scripts]
myapp = "mypackage.gui:main"Plugins
[project.entry-points."mypackage.plugins"]
myplugin = "myplugin:Plugin"Tool Configuration
Configure tools in the same file:
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.mypy]
python_version = "3.11"
strict = trueWhat to Ignore
Files that don't belong in packages:
# .gitignore
dist/
build/
*.egg-info/
__pycache__/
.venv/My Workflow
- Create
pyproject.tomlwith hatchling - Use
src/layout pip install -e ".[dev]"for developmentpython -m buildwhen ready to releasetwine upload dist/*to publish
Simple. Standard. Works everywhere.
Common Mistakes
Don't use setup.py for new projects. pyproject.toml is the standard.
Don't commit dist/. Build artifacts don't belong in git.
Don't forget requires-python. Specify your minimum version.
Don't pin exact versions in library dependencies. Use ranges.
Python packaging is solved. Use the standard tools.