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-vcsreads 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
- Modern Python Tooling 2026
- Python Type Hints 2026
- Python Async Patterns 2026
- Python Data Validation 2026
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 .