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.

[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.whl

Publishing

# 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 = true

What to Ignore

Files that don't belong in packages:

# .gitignore
dist/
build/
*.egg-info/
__pycache__/
.venv/

My Workflow

  1. Create pyproject.toml with hatchling
  2. Use src/ layout
  3. pip install -e ".[dev]" for development
  4. python -m build when ready to release
  5. twine 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.

React to this post: