🟣 Advanced

Packaging & pyproject.toml

📖 Lesson 36 ⏱ 45 min 🧪 5 questions 💻 3 exercises

🎯 Learning Objectives

  • Understand the Python packaging ecosystem and the role of pyproject.toml
  • Structure a distributable package with src/ layout
  • Configure build metadata with Hatchling, Flit, or Setuptools
  • Declare dependencies, optional extras, and version constraints
  • Build source distributions (sdist) and wheels with build
  • Publish packages to TestPyPI and PyPI with twine
  • Automate versioning and releases with tools like bump-my-version

1 · Why Package?

In Python a module is a single .py file, a package is a directory containing an __init__.py (or, since Python 3.3, a namespace package without one), and a distribution package is the archive you upload to PyPI so others can pip install it.

Why bother turning your code into a proper distribution package?

  • Reusability — install the same library in many projects without copy-pasting.
  • Dependency pinning — declare exactly what you need; pip resolves and installs it.
  • Reproducible installs — lockfiles and wheels guarantee identical environments.
  • Sharing — publish to PyPI and anyone in the world can pip install yourpackage.

The tooling has evolved significantly over the years:

  1. distutils (stdlib, now deprecated and removed in 3.12)
  2. setuptools with an imperative setup.py
  3. Declarative setup.cfg alongside a shim setup.py
  4. pyproject.toml — the modern standard defined by PEP 517, PEP 518, and PEP 621
pyproject.toml is the standard. As of 2024, pyproject.toml is the single canonical configuration file for Python projects. The old setup.py / setup.cfg approach still works but is considered legacy. New projects should always start with pyproject.toml.

2 · Project Layout

The recommended layout uses a src/ directory to hold your importable package:

mypackage/                  ← repo root
├── src/
│   └── mypackage/
│       ├── __init__.py
│       ├── core.py
│       └── utils.py
├── tests/
│   ├── __init__.py
│   └── test_core.py
├── pyproject.toml
├── README.md
└── LICENSE

Why the src/ layout?

  • Prevents accidentally importing your uninstalled source tree (Python adds . to sys.path, so without src/ you'd import from the repo root instead of the installed version).
  • Forces you to install the package (pip install -e .) before testing — catching packaging bugs early.
  • Produces cleaner wheel contents — only the code under src/ ships.

The tests/ directory lives outside src/ so it is not included in the built wheel by default.

A minimal __init__.py sets the public API and version:

"""mypackage — a demonstration package."""

__version__ = "0.1.0"

from mypackage.core import process_data
from mypackage.utils import slugify

__all__ = ["process_data", "slugify"]
src/mypackage/__init__.py

3 · pyproject.toml Anatomy

A pyproject.toml is composed of three logical sections:

  1. [build-system] — tells pip/build which backend to use.
  2. [project] — standard metadata (PEP 621): name, version, dependencies, etc.
  3. Tool-specific tables — e.g. [tool.hatch], [tool.pytest.ini_options], [tool.ruff].

Here is a complete, annotated example using Hatchling:

[build-system]
requires      = ["hatchling"]
build-backend = "hatchling.build"

[project]
name            = "mypackage"
version         = "0.1.0"
description     = "A short description of what mypackage does"
readme          = "README.md"
license         = { text = "MIT" }
requires-python = ">=3.10"
authors         = [{ name = "Alice Dev", email = "alice@example.com" }]
keywords        = ["example", "packaging"]
classifiers     = [
    "Development Status :: 3 - Alpha",
    "Intended Audience :: Developers",
    "License :: OSI Approved :: MIT License",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
]

dependencies = [
    "httpx>=0.27,<1",
    "pydantic>=2.0",
]

[project.optional-dependencies]
dev  = ["pytest>=8", "pytest-cov", "mypy", "ruff"]
docs = ["mkdocs", "mkdocs-material"]

[project.urls]
Homepage      = "https://github.com/alice/mypackage"
Documentation = "https://mypackage.readthedocs.io"
"Bug Tracker" = "https://github.com/alice/mypackage/issues"

[project.scripts]
mypackage-cli = "mypackage.cli:main"
pyproject.toml

Field-by-field breakdown:

  • name — the distribution name on PyPI. Must be unique; normalised to lowercase with hyphens replaced by dashes (e.g. My_Packagemy-package).
  • version — follows SemVer. Can also be dynamic (read from source).
  • description — one-line summary shown on PyPI.
  • readme — path to the long description file (rendered as the PyPI project page).
  • license — SPDX expression or { text = "..." }.
  • requires-python — minimum Python version using PEP 440 specifiers.
  • authors — list of { name, email } tables.
  • classifiersTrove classifiers for discovery on PyPI.
  • dependencies — runtime requirements using PEP 440 version specifiers.
  • optional-dependencies — extras installed via pip install mypackage[dev].
  • urls — links shown on the PyPI sidebar.
  • scripts — console entry points. "mypackage.cli:main" means "import main from mypackage.cli and call it".

4 · Version Specifiers & Dependency Pinning

PEP 440 defines how to express version constraints. Mastering these is critical for writing correct dependencies.

SpecifierMeaningWhen to use
>=1.2,<2At least 1.2, below 2.0Libraries — allows compatible updates
~=1.4.2Compatible release (≥1.4.2, <1.5.0)When you trust patch/minor semver
==1.2.3Exact pinLockfiles & applications only
!=1.3.*Exclude all 1.3.x releasesKnown-broken versions
(no specifier)Any version at all⚠️ Dangerous — avoid
Libraries vs Applications: In a library's pyproject.toml pin loosely (>=1.2,<2) so consumers can resolve compatible versions. In an application's lockfile (requirements.txt, uv.lock) pin tightly (==1.2.3) for reproducibility.

Tools for generating lockfiles from loose constraints:

  • pip-compile (from pip-tools) — pip-compile pyproject.toml -o requirements.lock
  • uv lock (from uv) — extremely fast Rust-based resolver

5 · Build Backends

PEP 517 introduced a clean separation between build frontends (like pip or python -m build) and build backends that actually produce the wheel. You pick a backend via [build-system]:

Backendrequires valueBest for
HatchlinghatchlingModern projects, zero config
Flit-coreflit_core>=3.2.0Pure-Python, minimal config
Setuptoolssetuptools>=61Legacy/complex builds, C extensions
MaturinmaturinRust extensions (PyO3)
scikit-build-corescikit-build-coreCMake / C++ extensions

Example [build-system] blocks:

# Hatchling (recommended default)
[build-system]
requires      = ["hatchling"]
build-backend = "hatchling.build"

# Flit
[build-system]
requires      = ["flit_core>=3.2.0"]
build-backend = "flit_core.buildapi"

# Setuptools (modern declarative)
[build-system]
requires      = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta"

# Maturin (Rust)
[build-system]
requires      = ["maturin>=1.0"]
build-backend = "maturin"
pyproject.toml (build-system variants)
Frontend ≠ Backend. PEP 517 decouples the build frontend (pip, build, uv) from the backend. You can swap backends without changing your build command — just update [build-system].

6 · Building a Distribution

Install the standard build frontend:

pip install build
terminal

Then build your package:

# Build both sdist and wheel (recommended)
python -m build

# Build only wheel
python -m build --wheel

# Build only source distribution
python -m build --sdist
terminal

After a successful build, the dist/ directory contains:

  • mypackage-0.1.0.tar.gz — the sdist (source distribution). Contains your source code plus pyproject.toml; anyone can build a wheel from it.
  • mypackage-0.1.0-py3-none-any.whl — the wheel. Pre-built, installs in milliseconds with no build step required.

Wheel filename anatomy:

{name}-{version}-{python_tag}-{abi_tag}-{platform_tag}.whl

mypackage-0.1.0-py3-none-any.whl
│             │   │    │     └─ any platform
│             │   │    └─ no ABI dependency
│             │   └─ Python 3 (any minor)
│             └─ version
└─ distribution name

A pure-Python wheel uses py3-none-any. Packages with C extensions produce platform-specific wheels like cp312-cp312-manylinux_2_17_x86_64.whl.

Build in isolation. By default python -m build creates a temporary virtualenv for the build. This ensures reproducibility. Only use --no-isolation if you have a specific reason (e.g. pre-installed build deps in CI).

7 · Publishing to PyPI

The full publish workflow:

# 1 — Install twine
pip install twine

# 2 — Check the distribution files for common errors
twine check dist/*

# 3 — Upload to TestPyPI first (always test before real PyPI)
twine upload --repository testpypi dist/*

# 4 — Install from TestPyPI to verify
pip install --index-url https://test.pypi.org/simple/ mypackage

# 5 — Publish to real PyPI
twine upload dist/*
terminal

Key points:

  • Create accounts at pypi.org and test.pypi.org (they are separate).
  • Use API tokens — not username/password. Store them in ~/.pypirc or as environment variables.
  • Package names must be globally unique on PyPI.
  • Once a version is uploaded it cannot be overwritten — always bump the version for fixes.

A minimal ~/.pypirc:

[distutils]
index-servers = pypi testpypi

[pypi]
repository = https://upload.pypi.org/legacy/
username   = __token__
password   = pypi-<YOUR_API_TOKEN>

[testpypi]
repository = https://test.pypi.org/legacy/
username   = __token__
password   = pypi-<YOUR_TESTPYPI_TOKEN>
~/.pypirc
Never commit API tokens to git. Use environment variables (TWINE_USERNAME / TWINE_PASSWORD) or a secrets manager in CI. Add .pypirc to your global gitignore.

8 · Automating Releases with CI

A minimal GitHub Actions workflow that publishes to PyPI whenever you push a version tag:

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_API_TOKEN }}
.github/workflows/publish.yml

Trusted Publishing (OIDC) — the modern, zero-secret alternative. Instead of storing a long-lived API token, you configure PyPI to trust your GitHub Actions workflow directly via OpenID Connect. No secrets to rotate, no tokens to leak. See the PyPI Trusted Publishers documentation.

Automating version bumps with bump-my-version:

# Install
pip install bump-my-version

# Bump patch: 0.1.0 → 0.1.1 (commits + tags automatically)
bump-my-version bump patch

# Bump minor: 0.1.1 → 0.2.0
bump-my-version bump minor

# Bump major: 0.2.0 → 1.0.0
bump-my-version bump major

# Push the tag to trigger the CI publish workflow
git push --follow-tags
terminal

Configure it in pyproject.toml:

[tool.bumpversion]
current_version = "0.1.0"
commit          = true
tag             = true

[[tool.bumpversion.files]]
filename = "pyproject.toml"
search   = 'version = "{current_version}"'
replace  = 'version = "{new_version}"'

[[tool.bumpversion.files]]
filename = "src/mypackage/__init__.py"
search   = '__version__ = "{current_version}"'
replace  = '__version__ = "{new_version}"'
pyproject.toml (bump-my-version config)

With this setup, a single bump-my-version bump patch && git push --follow-tags updates the version everywhere, commits, tags, and pushes — triggering your CI to build and publish automatically.

Editable Installs & Development Workflow

During development you want changes to your source files to be reflected immediately without reinstalling. An editable install (also called a "develop install") links the package into your environment rather than copying it.

# Install your package in editable mode
pip install -e .

# Install with optional dev extras too
pip install -e ".[dev]"

# Verify it is listed as editable
pip show mypackage        # Location points to your src/ directory
terminal

With an editable install, editing src/mypackage/core.py is immediately visible to any script that imports mypackage — no reinstall required. Tests, scripts, and notebooks all see live changes.

PEP 660 standardised editable installs for PEP 517 backends. Hatchling, Setuptools ≥ 64, and Flit-core all support it. If pip install -e . fails, upgrade pip: pip install --upgrade pip.

Recommended Daily Workflow

  1. Create and activate a virtual environment: python -m venv .venv && source .venv/bin/activate
  2. Editable-install with dev extras: pip install -e ".[dev]"
  3. Code, run tests with pytest, lint with ruff check .
  4. Bump version: bump-my-version bump patch
  5. Build: python -m build — inspect dist/
  6. Publish: twine upload dist/* (or push a tag to trigger CI)

Best Practices

  • Always use the src/ layout — it prevents your uninstalled package from being importable by accident and mirrors how it will behave once installed.
  • Specify requires-python — prevents installation on unsupported Python versions and communicates your support matrix clearly.
  • Pin loosely in library dependencies — use ranges like httpx>=0.27,<1 in pyproject.toml; reserve tight pins for application lockfiles (requirements.txt, uv.lock).
  • Use API tokens, not passwords — create per-project tokens on PyPI with minimal scope; rotate them regularly.
  • Test on TestPyPI first — once a version is uploaded to PyPI it cannot be deleted or overwritten. Always verify with TestPyPI.
  • Automate with CI — tag-triggered GitHub Actions workflows remove human error from the release process.
  • Include a LICENSE file — without one, legally no one can use your code. MIT or Apache-2.0 are standard open-source choices.
  • Write a useful README.md — it becomes your PyPI landing page. Include install instructions, a quick-start example, and a link to full docs.
Modern alternative: uvuv (from Astral) is a next-generation package manager that replaces pip, pip-tools, venv, and twine in one blazing-fast tool. uv build builds distributions; uv publish uploads them; uv lock generates a lockfile. It reads the same pyproject.toml — no migration needed.

Exercises

Exercise 1 — Create and Install a Package

Build a distributable package called texttools from scratch:

  • Create the src/texttools/ layout with __init__.py and transforms.py.
  • Implement three functions in transforms.py: slugify(text) (lowercase, spaces→hyphens, strip non-alphanumeric), truncate(text, max_len, suffix="…"), and word_count(text) -> dict[str, int].
  • Write a pyproject.toml using Hatchling with requires-python = ">=3.10", no external dependencies, and a dev extra for pytest.
  • Install with pip install -e ".[dev]" and verify import texttools works.
  • Write three pytest tests (one per function) in tests/test_transforms.py and run them with pytest -v.
💡 Hint — pyproject.toml
[build-system]
requires      = ["hatchling"]
build-backend = "hatchling.build"

[project]
name            = "texttools"
version         = "0.1.0"
description     = "Handy text transformation utilities"
readme          = "README.md"
requires-python = ">=3.10"
dependencies    = []

[project.optional-dependencies]
dev = ["pytest>=8"]
💡 Hint — transforms.py
import re
from collections import Counter

def slugify(text: str) -> str:
    text = text.lower().strip()
    text = re.sub(r"[^\w\s-]", "", text)
    return re.sub(r"[\s_]+", "-", text)

def truncate(text: str, max_len: int, suffix: str = "…") -> str:
    if len(text) <= max_len:
        return text
    return text[: max_len - len(suffix)] + suffix

def word_count(text: str) -> dict[str, int]:
    words = re.findall(r"\b\w+\b", text.lower())
    return dict(Counter(words))

Exercise 2 — Optional Extras & Entry Points

Extend the texttools package from Exercise 1:

  • Add a cli.py module with a main() function that accepts a subcommand: texttools-cli slugify "Hello World", texttools-cli wordcount "some text here".
  • Register it as a [project.scripts] entry point in pyproject.toml.
  • Add an http optional extra that depends on httpx>=0.27; add a fetch.py module that uses it.
  • Re-install with pip install -e ".[dev,http]" and verify the CLI works: texttools-cli slugify "Hello World" should print hello-world.
💡 Hint — cli.py
import sys
from texttools.transforms import slugify, word_count

def main() -> None:
    if len(sys.argv) < 3:
        print("Usage: texttools-cli  ")
        sys.exit(1)
    cmd, text = sys.argv[1], sys.argv[2]
    if cmd == "slugify":
        print(slugify(text))
    elif cmd == "wordcount":
        for word, count in sorted(word_count(text).items()):
            print(f"{word}: {count}")
    else:
        print(f"Unknown command: {cmd}")
        sys.exit(1)
💡 Hint — pyproject.toml additions
[project.scripts]
texttools-cli = "texttools.cli:main"

[project.optional-dependencies]
dev  = ["pytest>=8"]
http = ["httpx>=0.27,<1"]

Exercise 3 — Build, Check & Publish to TestPyPI

Take texttools through the full release pipeline:

  • Ensure your README.md has an install snippet and a 3-line usage example.
  • Run python -m build — confirm both a .tar.gz and a .whl appear in dist/.
  • Run twine check dist/* — fix any warnings before continuing.
  • Create a free account at test.pypi.org, generate an API token, and upload: twine upload --repository testpypi dist/*.
  • In a fresh virtualenv, install from TestPyPI and confirm the CLI works end-to-end.
💡 Full command sequence
# Build
python -m build

# Check metadata
twine check dist/*

# Upload to TestPyPI
TWINE_USERNAME=__token__ TWINE_PASSWORD=pypi-<token> \
  twine upload --repository-url https://test.pypi.org/legacy/ dist/*

# Fresh environment test
python -m venv /tmp/test-env
source /tmp/test-env/bin/activate
pip install --index-url https://test.pypi.org/simple/ texttools
texttools-cli slugify "Hello World"    # → hello-world