Python packaging in 2026 is the best it’s ever been. uv made dependency management instant; pyproject.toml replaced setup.py for nearly everyone; building wheels is no longer arcane. This post is the working playbook for shipping a Python package today.

pyproject.toml as the source of truth

[project]
name = "mypackage"
version = "0.1.0"
description = "What it does"
authors = [{name = "You", email = "[email protected]"}]
license = "MIT"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
    "httpx>=0.27",
    "pydantic>=2.0",
]

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

[project.urls]
Homepage = "https://github.com/you/mypackage"

[project.scripts]
mycli = "mypackage.cli:main"

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

One file. No setup.py. No setup.cfg. Tools read this directly.

src layout

mypackage/
├── pyproject.toml
├── src/
│   └── mypackage/
│       ├── __init__.py
│       ├── core.py
│       └── cli.py
├── tests/
│   └── test_core.py
└── README.md

Why src/: prevents import mypackage from picking up the source tree before the installed package; tests run against the installed version (or editable install), like real users.

With uv

# Init
uv init mypackage

# Add deps
uv add httpx pydantic

# Add dev deps
uv add --dev pytest ruff

# Run
uv run pytest

# Build
uv build

# Publish
uv publish

uv handles venv, locking, and the build steps. No pip install -e .uv sync does it.

With hatch

hatch new mypackage
hatch env create
hatch run pytest
hatch build
hatch publish

hatch’s environment management lets you parameterize matrix testing (3.11 / 3.12 / 3.13) cleanly.

Wheels

uv build
# dist/mypackage-0.1.0-py3-none-any.whl
# dist/mypackage-0.1.0.tar.gz

.whl is the binary distribution; preferred. .tar.gz is the source distribution; fallback for platforms without a matching wheel.

For pure-Python: one wheel works everywhere. For C/Rust extensions: per-platform wheels.

Native extensions (Rust via PyO3)

[build-system]
requires = ["maturin"]
build-backend = "maturin"

[tool.maturin]
features = ["pyo3/extension-module"]
maturin develop  # build + install editable
maturin build --release

maturin produces wheels per platform. CI builds for linux x86_64/aarch64, macos x86_64/arm64, windows.

For native C: cibuildwheel runs in CI to produce all wheels.

Cibuildwheel in CI

# .github/workflows/wheels.yml
- uses: pypa/cibuildwheel@v2
  env:
    CIBW_BUILD: "cp311-* cp312-* cp313-*"
    CIBW_ARCHS_MACOS: "x86_64 arm64"
    CIBW_ARCHS_LINUX: "x86_64 aarch64"

Builds wheels for every supported (Python × OS × arch). Uploads to PyPI on tag.

Publishing

# Manual
uv publish --token $PYPI_TOKEN

# Or, automated via GitHub Actions trusted publishing
# (no token; OIDC)

Trusted publishing (PyPI’s OIDC) is the new default — no secret management. Configure once in PyPI; CI gets a token per workflow run.

Versioning

Single-source-of-truth strategies:

  • Static: version = "0.1.0" in pyproject.toml; bump manually.
  • Dynamic from VCS: hatch + hatch-vcs reads the git tag.
  • Dynamic from package: hatch reads mypackage.__version__.

For most packages: SemVer + manually bumped. For libraries with frequent releases: VCS-based.

[tool.hatch.version]
source = "vcs"

Lockfiles

uv writes uv.lock; commit it for applications. For libraries (published packages): don’t pin in pyproject; users solve their own deps.

Optional deps

[project.optional-dependencies]
postgres = ["asyncpg>=0.29"]
redis = ["redis>=5.0"]
all = ["mypackage[postgres,redis]"]
pip install mypackage[postgres,redis]

Lets users install only what they need.

Pre-1.0

For packages still in flux:

  • Use 0.x; users expect breaking changes.
  • Document deprecations in the changelog.
  • Bump 0.x.0 for each minor breaking change; 0.x.y for patches.

Once stable: 1.0; SemVer strict.

CHANGELOG.md

# Changelog

## [Unreleased]

## [0.2.0] - 2026-05-01
### Added
- Async client.
### Changed
- `connect()` now requires `url=` keyword.

Keep a Changelog format. Helps you and your users.

Common mistakes

1. setup.py-only

Old projects still use this. Migrate to pyproject.toml + a build backend.

2. Pinning library deps

requests==2.31.5 in your library’s pyproject. Now everyone using your lib is stuck on that version. Use ranges (>=2.31).

3. No src layout

Tests import in-tree code; you ship a package that doesn’t actually work when installed.

4. Forgotten __init__.py

Implicit namespace packages can work but cause subtle issues. Add __init__.py to clarify.

5. Including test files in the wheel

Wheel ships with tests/ and pytest.ini and 100MB of fixtures. Configure tool.hatch.build or MANIFEST.in to exclude.

What I’d ship today

For a new Python project:

  • uv for everything (init, deps, run, build, publish).
  • pyproject.toml as source of truth.
  • src/ layout.
  • ruff + mypy in CI.
  • GitHub Actions with trusted publishing.
  • cibuildwheel if native extensions.
  • Keep a Changelog.
  • SemVer post-1.0.

Read this next

If you want my pyproject.toml + GitHub Actions starter, it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .