Python Packaging

Structure a project, write pyproject.toml, build wheels, and publish to PyPI - the complete modern workflow.

Intermediate

Package Structure

A modern Python package uses the src layout - it prevents accidentally importing from the source directory during testing:

Bash
my-package/
    src/
        mypackage/
            __init__.py
            core.py
            utils.py
            submodule/
                __init__.py
                feature.py
    tests/
        conftest.py
        test_core.py
    docs/
    pyproject.toml
    README.md
    LICENSE
    .gitignore

The __init__.py can be empty, or it can re-export the public API:

Python
from .core import Client, Response
from .utils import retry, timeout

__version__ = '1.0.0'
__all__ = ['Client', 'Response', 'retry', 'timeout']

Add a py.typed marker file (empty) to signal that your package supports type checking (PEP 561):

Bash
# Create empty py.typed marker
touch src/mypackage/py.typed

pyproject.toml

The single configuration file for build, metadata, and tools:

Python
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.backends.legacy:build"

[project]
name = "my-package"
version = "1.0.0"
description = "A brief description of what this package does"
readme = "README.md"
license = {file = "LICENSE"}
authors = [
    {name = "Alice Smith", email = "alice@example.com"},
]
keywords = ["python", "example", "tutorial"]
classifiers = [
    "Development Status :: 5 - Production/Stable",
    "License :: OSI Approved :: MIT License",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
]
requires-python = ">=3.10"
dependencies = [
    "requests>=2.28",
    "click>=8.1",
]

[project.optional-dependencies]
dev = [
    "pytest>=8.0",
    "mypy>=1.10",
    "black>=24.0",
]

[project.urls]
Homepage    = "https://github.com/alice/my-package"
Repository  = "https://github.com/alice/my-package"
"Bug Tracker" = "https://github.com/alice/my-package/issues"
Changelog   = "https://github.com/alice/my-package/CHANGELOG.md"

[tool.setuptools.packages.find]
where = ["src"]

[tool.setuptools.package-data]
mypackage = ["py.typed", "*.json"]

Install optional dependencies with:

Bash
# Install with dev extras
pip install "my-package[dev]"

# Install local package in editable mode
pip install -e ".[dev]"

Building a Distribution

Use the build package to create wheel and sdist distributions:

Bash
pip install build

# Build both wheel and sdist
python -m build

# Build only wheel
python -m build --wheel

# Build only sdist
python -m build --sdist

After building, the dist/ directory contains:

Bash
dist/
    my_package-1.0.0-py3-none-any.whl   # wheel (binary distribution)
    my_package-1.0.0.tar.gz             # sdist (source distribution)

# Wheel filename format: {name}-{version}-{python}-{abi}-{platform}.whl
# py3-none-any = pure Python 3, no ABI, any platform
# cp312-cp312-linux_x86_64 = CPython 3.12 extension module

Check the built distributions with twine check before uploading:

Bash
pip install twine
twine check dist/*

Publishing to PyPI

First test on TestPyPI, then publish to the real PyPI:

Bash
# Upload to TestPyPI first
twine upload --repository testpypi dist/*

# Test install from TestPyPI
pip install --index-url https://test.pypi.org/simple/ my-package

# Upload to real PyPI
twine upload dist/*

Set up PyPI credentials with an API token (more secure than password):

Bash
[pypi]
username = __token__
password = pypi-AgEIcHlwaS5vcmcCJDEyMzQ1...  # your API token

[testpypi]
username = __token__
password = pypi-AgENdGVzdC5weXBpLm9yZwIk...  # your TestPyPI token

Or use environment variables (preferred for CI/CD):

Bash
export TWINE_USERNAME=__token__
export TWINE_PASSWORD=pypi-AgEI...
twine upload dist/*

GitHub Actions workflow for automatic publishing on tag push:

Bash
name: Publish to PyPI

on:
  push:
    tags: ['v*']

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: {python-version: '3.12'}
      - run: pip install build twine
      - run: python -m build
      - run: twine upload dist/*
        env:
          TWINE_USERNAME: __token__
          TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}

Entry Points (CLI scripts)

Entry points create console scripts that users can run as commands after installing your package:

Python
[project.scripts]
my-tool = "mypackage.cli:main"
my-tool-debug = "mypackage.cli:main_debug"
Python
import click

@click.command()
@click.argument('name')
@click.option('--count', default=1, help='Number of greetings')
def main(name, count):
    for _ in range(count):
        click.echo(f'Hello, {name}!')

if __name__ == '__main__':
    main()
Bash
pip install -e .
my-tool Alice --count 3
# Hello, Alice!
# Hello, Alice!
# Hello, Alice!

Versioning

Python packages should follow Semantic Versioning (SemVer): MAJOR.MINOR.PATCH:

IncrementWhenExample
MAJORBreaking changes to public API1.0.0 -> 2.0.0
MINORNew backward-compatible features1.0.0 -> 1.1.0
PATCHBackward-compatible bug fixes1.0.0 -> 1.0.1

Single-source versioning - define version once, use everywhere:

Python
__version__ = '1.0.0'
Python
[project]
# Option 1: version in pyproject.toml
version = "1.0.0"

# Option 2: read from __init__.py dynamically (requires setuptools>=61)
dynamic = ["version"]

[tool.setuptools.dynamic]
version = {attr = "mypackage.__version__"}

Access the installed version at runtime:

Python
from importlib.metadata import version, PackageNotFoundError

try:
    __version__ = version('my-package')
except PackageNotFoundError:
    __version__ = 'unknown'  # package not installed
Use Flit or Hatch for simpler pure-Python packages

For pure-Python packages without C extensions, Flit provides a minimal build backend with almost no configuration - great for libraries. Hatch is a modern, feature-rich alternative to setuptools with built-in environment management, test orchestration, and versioning. Choose setuptools if you need C extensions or maximum compatibility.

Frequently Asked Questions

An sdist (source distribution) is a .tar.gz containing your source code and build instructions. The installer must compile/build it. A wheel (.whl) is a binary distribution - a zip file in the final install format. Installing a wheel is fast (no build step). Always publish both: wheel for most users, sdist as a fallback and for auditing.

pyproject.toml is the modern standard configuration file for Python packages (PEP 517/518). It replaces setup.py and setup.cfg. It specifies the build backend (setuptools, Flit, Poetry, Hatch), project metadata (name, version, dependencies), and tool configuration (pytest, mypy, black). All modern packaging tools read it.

For regular packages (importable with import mypackage), yes. __init__.py marks a directory as a Python package. It can be empty or contain re-exports. For "namespace packages" (PEP 420) where the package is split across multiple directories, you omit __init__.py. For modern packaging with setuptools find_packages(), directories without __init__.py are not auto-discovered by default.

TestPyPI is a separate instance of PyPI for testing package publishing without affecting the real PyPI. Use it to test your upload workflow before publishing officially. Create a separate account at test.pypi.org and upload with twine upload --repository testpypi dist/*. Packages on TestPyPI are occasionally deleted.