Python Library Packaging
pyproject.toml Essentials
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "my-package"
version = "1.0.0"
description = "Short description"
readme = "README.md"
requires-python = ">=3.10"
license = {text = "MIT"}
dependencies = []
[project.optional-dependencies]
dev = ["pytest>=7.0", "ruff>=0.1", "mypy>=1.0"]
[project.urls]
Homepage = "https://github.com/user/package"
Documentation = "https://package.readthedocs.io"
[project.scripts]
mycli = "my_package.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
Building
uv build # Creates sdist + wheel in dist/
uvx twine check dist/* # Validate metadata
Publishing to PyPI
Prefer trusted publishing from CI (no stored token) — see the CI section
below. For a manual publish, upload with twine via uvx:
uvx twine upload --repository testpypi dist/* # Test first
uvx twine upload dist/* # Production (uses a PyPI token)
GitHub Actions (Trusted Publishing)
Publishing is automated by the canonical tag-triggered release workflow in the
managing-python-releases skill — one pipeline builds with uv and publishes via
trusted publishing. See ../release-management/AUTOMATION.md;
don't maintain a second copy here.
Dependency Best Practices
# DO: Minimum versions
dependencies = ["requests>=2.28", "click>=8.0"]
# DON'T: Exact pins (locks users)
dependencies = ["requests==2.28.1"]
# DO: Optional for features
[project.optional-dependencies]
cli = ["click>=8.0"]
Including Package Data
[tool.setuptools.package-data]
my_package = ["py.typed", "data/*.json"]
from importlib.resources import files
data = files("my_package.data").joinpath("file.json").read_text()
Direct-Reference Dependencies Need a Real Build
A dependency such as toolkit @ https://example.com/toolkit.whl may resolve in
an install dry run without invoking the build backend. With Hatchling, the real
wheel build then fails unless direct references are explicitly allowed:
[project]
dependencies = [
"toolkit @ https://example.com/releases/toolkit.whl",
]
[tool.hatch.metadata]
allow-direct-references = true
Do not use uv pip install --dry-run . as the packaging check for this case. It
can report success without building the project. Run uv build (or a real
uv pip install .) so the configured backend validates the metadata:
uv build
uvx twine check dist/*
For detailed templates, see:
- ../project-setup/PYPROJECT.md - Complete annotated pyproject.toml (canonical)
- CONDA.md - Conda / conda-forge packaging guide
Verify the Built Artifact (a green build is not a correct wheel)
uv build succeeding tells you the backend ran, not that the wheel
contains your code. Build backends select files via config — hatchling's
[tool.hatch.build.targets.wheel] (only-include / packages / include),
setuptools' [tool.setuptools.packages.find]. Get that config wrong and the
backend cheerfully ships a wheel that is missing subpackages or data files, with
no error. twine check won't catch it either — it validates metadata, not
contents.
Always inspect the wheel and install it clean before publishing:
uv build
uv run python -m zipfile -l dist/*.whl # list every file the wheel contains
# ^ confirm ALL your subpackages (my_pkg/, my_pkg/sub/) and data files are there,
# not just the top-level module.
The most common footgun is over-narrow file selection. This ships only
server.py and silently drops the whole server/ package and data/:
# DON'T — over-narrow include drops everything else
[tool.hatch.build]
only-include = ["server.py"]
# DO — include the package (and any data dirs); let the backend walk it
[tool.hatch.build.targets.wheel]
packages = ["src/my_pkg"]
Name-collision footgun: never ship both a top-level module foo.py and a
package directory foo/. The package shadows the module, so import foo
resolves to the (often nearly empty) foo/__init__.py, and a console entry point
foo = "foo:main" fails because that package has no main. Pick one — usually
the package — and delete the other.
Then prove it from a clean install, not from the source tree:
uv venv /tmp/verify && uv pip install --python /tmp/verify/bin/python dist/*.whl
cd /tmp && /tmp/verify/bin/python -c "import my_pkg; my_pkg.submodule.real_func"
mycli --help # exercise each console script too
Run it from a directory other than the repo root — otherwise import my_pkg
picks up the source tree on sys.path and "works" even when the wheel is empty.
And assert on a real symbol (my_pkg.submodule.real_func), never just that the
bare top-level name imports: import foo can succeed against a shadowing empty
package and prove nothing. A CI smoke test that only does import foo; print("ok")
is a false green — it passes whether or not the distributed package is usable.
Checklist
Before Release:
- [ ] pyproject.toml valid
- [ ] README.md informative
- [ ] LICENSE file exists
- [ ] Version set correctly
- [ ] twine check passes
- [ ] `uv run python -m zipfile -l dist/*.whl` shows every subpackage + data file
- [ ] Direct-reference dependencies pass a real backend build (not only install dry-run)
- [ ] No module/package name collision (no foo.py AND foo/)
- [ ] Installed the wheel into a clean venv and imported a real submodule
symbol from a directory outside the repo (not just the top-level name)
- [ ] Each console script runs after a clean install
After Release:
- [ ] pip install works
- [ ] Import works
- [ ] GitHub release created
Learn More
This skill is based on the Distribution section of the Guide to Developing High-Quality Python Libraries by Will McGinnis. See these posts for deeper coverage: