Packaging, Wheel Distribution, and Publishing
Packaging, Wheel Distribution, and Publishing
After reading this chapter, you will master the modern Python packaging standards (PEP 517, PEP 518, PEP 621), author declarative pyproject.toml specifications, configure executable CLI console scripts, build standard source distributions (.tar.gz) and built wheel packages (.whl) with uv build, and inspect package distribution metadata.
Mental model
In legacy Python, packaging relied on imperative setup.py scripts that executed arbitrary Python code during installation. Modern packaging uses declarative TOML metadata (pyproject.toml) and isolated build frontends:
Modern Packaging Architecture:
pyproject.toml (PEP 621 Metadata & Build Backend)
│
▼ uv build
┌─────────────────────────┴─────────────────────────┐
▼ ▼
Source Distribution (sdist): Built Wheel (.whl):
mypkg-0.1.0.tar.gz mypkg-0.1.0-py3-none-any.whl
├── Raw Python source code ├── Pre-built package files
└── pyproject.toml └── mypkg-0.1.0.dist-info/
├── METADATA
├── WHEEL
├── entry_points.txt
└── RECORD (SHA-256 hashes)
A wheel (.whl) is a ZIP archive formatted with exact installation targets. Installing a wheel is simply an unpack and copy operation into site-packages with zero build step execution.
Minimal example
Save as package_builder_demo.py:
# package_builder_demo.py
import tempfile
import zipfile
from pathlib import Path
SAMPLE_PYPROJECT = """
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "cluster-agent"
version = "1.0.0"
description = "Automated cluster monitoring telemetry agent."
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"rich>=13.0.0",
]
[project.scripts]
cluster-agent = "cluster_agent.cli:main"
"""
def generate_package_skeleton(root: Path) -> None:
(root / "pyproject.toml").write_text(SAMPLE_PYPROJECT.strip())
(root / "README.md").write_text("# Cluster Agent\nHigh-speed cluster agent.")
pkg_dir = root / "cluster_agent"
pkg_dir.mkdir()
(pkg_dir / "__init__.py").write_text('__version__ = "1.0.0"\n')
(pkg_dir / "cli.py").write_text("""
def main() -> None:
print("Cluster agent running.")
""")
def main() -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
generate_package_skeleton(root)
print("Generated modern package skeleton with pyproject.toml:")
for path in sorted(root.rglob("*")):
if path.is_file():
print(f" - {path.relative_to(root)}")
if __name__ == "__main__":
main()Run via uv run python package_builder_demo.py:
Generated modern package skeleton with pyproject.toml:
- README.md
- cluster_agent/__init__.py
- cluster_agent/cli.py
- pyproject.toml
Worked examples
Case 1: Defining CLI Console Scripts via [project.scripts]
When users install your package, they often want a global command-line command (my-tool) available in their shell. Modern pyproject.toml declares this declaratively:
[project.scripts]
# CLI command name = "module_path:function_to_call"
infra-cli = "infra_pkg.cli:main_entrypoint"When installed, pip or uv automatically generates an executable binary wrapper in the environment’s bin/ directory that executes main_entrypoint().
Case 2: Building Wheels with uv build
Executing uv build reads pyproject.toml, prepares an isolated build environment, and outputs standards-compliant artifacts into dist/:
# Execute build
uv build
# Output:
# Successfully built dist/cluster_agent-1.0.0.tar.gz
# Successfully built dist/cluster_agent-1.0.0-py3-none-any.whlCase 3: Inspecting Wheel Metadata (.dist-info)
Because a .whl file is a ZIP archive, you can inspect its metadata directly:
# inspect_wheel.py
import zipfile
def inspect_wheel_metadata(wheel_path: str) -> None:
with zipfile.ZipFile(wheel_path, "r") as zf:
metadata_files = [name for name in zf.namelist() if name.endswith("METADATA")]
if metadata_files:
meta_text = zf.read(metadata_files[0]).decode("utf-8")
print("--- Package METADATA Content ---")
for line in meta_text.splitlines()[:10]:
print(line)
if __name__ == "__main__":
print("Wheel metadata inspection helper loaded.")Run:
uv run python inspect_wheel.pyOutput:
Wheel metadata inspection helper loaded.
Pitfalls
Pitfall 1: Relying on Legacy setup.py
Modern toolchains (uv, pip, build) deprecate setup.py in favor of declarative pyproject.toml. Imperative setup.py scripts introduce build-time security vulnerabilities and prevent static dependency analysis.
Pitfall 2: Missing Package Data Files
If your package relies on static templates, HTML files, or YAML configs, you must explicitly declare them in your build backend configuration:
# Example for hatchling backend:
[tool.hatch.build.targets.wheel]
packages = ["cluster_agent"]
include = [
"cluster_agent/templates/*.html",
]Otherwise, wheels will package only .py source files, causing FileNotFoundError at runtime.
Exercises
- Write a minimal
pyproject.tomlusinghatchlingas the build backend, declaring a console script entry point. - Build a wheel package using
uv buildin a temporary test directory and verify the files generated indist/. - Unzip a
.whlarchive and verify the contents of itsentry_points.txtandRECORDfiles. - Configure optional dependency groups (
[project.optional-dependencies]) fordevandtestsuites.
Further reading
- PEP 517: A build-system independent format for source trees.
- PEP 518: Specifying Minimum Build System Requirements for Python Projects.
- PEP 621: Storing project metadata in pyproject.toml.
- Python Packaging Authority (PyPA): Packaging Python Projects.