Expert · Lesson 83 of 94

Packaging and publishing

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

Concept

Packaging turns your project folder into files other people can install. There are two: a source distribution, .tar.gz, which is your code plus metadata, and a wheel, .whl, which is a pre-built zip that pip can unpack straight into site-packages without running anything. uv build or python -m build produces both into dist/ using the [build-system] from pyproject.toml. Everything the previous lesson set up, name, version, dependencies, entry points, becomes metadata inside these files.

Packaging and publishing: pyproject.toml + src/ → dist/expenses_cli-0.1.0.tar.gz; dist/expenses_cli-0.1.0-py3-none-any.whl → TestPyPI, then PyPI → a user's site-packages + the `expenses` command
Packaging and publishing · Figure 1 · Open for full size

A wheel is a zip file with a fixed layout: the package folder as it will appear in site-packages, a .dist-info folder containing METADATA (your [project] table rendered), RECORD (every file and its hash), and entry_points.txt (your [project.scripts]), and for pure-Python code the tag py3-none-any, meaning any Python 3 on any platform. pip install downloads the wheel, verifies it, copies the files, and writes the console script; no code of yours runs during install. That is why wheels are preferred and why a project that only ships an sdist installs more slowly and less safely.

Artifact / concept What it is
sdist .tar.gz source + metadata; built on the user's machine if needed
wheel .whl pre-built zip; installed by copying
py3-none-any wheel tag: pure Python, any platform
METADATA, RECORD rendered project table; file list with hashes
semantic version X.Y.Z major: breaking, minor: features, patch: fixes
TestPyPI a scratch index for practicing uploads
API token the only credential to publish with; scoped per project

Versions follow semantic versioning: MAJOR.MINOR.PATCH. Increment patch for fixes that change no behavior users rely on, minor for new features that stay compatible, major for anything that breaks existing callers. Before 1.0.0 the rules are looser by convention, and moving to 1.0.0 is a promise of stability. Keep a CHANGELOG.md with a section per version listing what changed, tag each release in Git with v0.1.0, and never reuse a version: an index refuses a second upload of the same number, and users need distinct versions to mean distinct code.

Publishing means uploading dist/* to an index. TestPyPI is a separate index for practicing; use it first, install from it into a fresh environment, and confirm the command runs. Then publish to PyPI. Authenticate with an API token created in your account settings, scoped to the project, stored as an environment variable or in the CI system's secrets, never typed into a file that could be committed. The CI lesson shows publishing from a workflow on a tag; the shape is the same commands with the token supplied by the runner.

Packaging and publishing: bump version in pyproject.toml + CHANGELOG → git commit; git tag v0.2.0 → uv build → dist/ → check: unzip -l wheel; install into a fresh venv; run the command → uv publish --publish-url TestPyPI → pip install -i test.pypi.org ... ; verify → uv publish (PyPI) with UV_PUBLISH_TOKEN
Packaging and publishing · Figure 2 · Open for full size

Code

Every lesson runs on its own, so the project from the previous lesson is recreated first: the same pyproject.toml and the same src layout, written by a short script.

# pyproject.toml
[project]
name = "expenses-cli"
version = "0.1.0"
description = "Parse expense CSVs and produce reports"
readme = "README.md"
requires-python = ">=3.14"
license = "MIT"
authors = [{ name = "Ada Lovelace", email = "ada@example.com" }]
dependencies = [
    "httpx>=0.28,<0.29",
]

[project.scripts]
expenses = "expenses.cli:main"

[project.optional-dependencies]
export = ["openpyxl>=3.1,<4"]

[dependency-groups]
dev = ["pytest==9.1.1", "mypy==2.3.1", "ruff==0.16.6"]

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

[tool.hatch.build.targets.wheel]
packages = ["src/expenses"]   # the import name differs from the project name, so say where the code is

[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.mypy]
strict = true

[tool.ruff]
line-length = 100
# scaffold.py
from pathlib import Path

root = Path("expenses-cli")
for folder in ("src/expenses", "tests"):
    (root / folder).mkdir(parents=True, exist_ok=True)
(root / "src/expenses/__init__.py").write_text('"""Expense reports."""\n\n__version__ = "0.1.0"\n')
(root / "src/expenses/cli.py").write_text(
    "import sys\n\n\ndef main(argv=None):\n    print('expenses', __import__('expenses').__version__)\n    return 0\n\n\n"
    "if __name__ == '__main__':\n    sys.exit(main())\n"
)
(root / "pyproject.toml").write_text(Path("pyproject.toml").read_text())
(root / "README.md").write_text("# expenses-cli\n")
print(sorted(p.relative_to(root).as_posix() for p in root.rglob("*") if p.is_file()))
Output
['README.md', 'pyproject.toml', 'src/expenses/__init__.py', 'src/expenses/cli.py']

Building the project. uv build writes both artifacts into dist/; the wheel's filename encodes the name, version, and tags.

# build.sh
cd expenses-cli
uv build -q --python 3.14.7
ls dist
Output
expenses_cli-0.1.0-py3-none-any.whl
expenses_cli-0.1.0.tar.gz

Inside the wheel: the package files, the metadata rendered from pyproject.toml, and the console entry point.

# inspect_wheel.py
import zipfile
from pathlib import Path

wheel = next(Path("expenses-cli/dist").glob("*.whl"))
with zipfile.ZipFile(wheel) as zf:
    names = sorted(zf.namelist())
    print([n for n in names if not n.startswith("expenses_cli-")])
    print([n.split("/")[-1] for n in names if n.startswith("expenses_cli-")])
    metadata = zf.read(next(n for n in names if n.endswith("METADATA"))).decode()
    print([ln for ln in metadata.splitlines() if ln.startswith(("Name:", "Version:", "Requires-Python:", "Requires-Dist:"))])
    print(zf.read(next(n for n in names if n.endswith("entry_points.txt"))).decode().strip().splitlines())
Output
['expenses/__init__.py', 'expenses/cli.py']
['METADATA', 'RECORD', 'WHEEL', 'entry_points.txt']
['Name: expenses-cli', 'Version: 0.1.0', 'Requires-Python: >=3.14', 'Requires-Dist: httpx<0.29,>=0.28', "Requires-Dist: openpyxl<4,>=3.1; extra == 'export'"]
['[console_scripts]', 'expenses = expenses.cli:main']

Installing the wheel into a fresh environment, exactly as a user would, and running the installed command. This is the check to do before any upload.

# verify_install.sh
cd expenses-cli
uv venv -q fresh --python 3.14.7
uv pip install -q dist/expenses_cli-0.1.0-py3-none-any.whl --python fresh/bin/python
fresh/bin/expenses
fresh/bin/python -c "import expenses, pathlib; print(pathlib.Path(expenses.__file__).parent.parent.name)"
Output
expenses 0.1.0
site-packages

Versioning and the release commands. The version is bumped in one place, the changelog gets a section, the tag matches, and the publish commands are shown with the token coming from the environment, not the command line.

# bump_version.py
import re
from pathlib import Path

root = Path("expenses-cli")
py = root / "pyproject.toml"
text = py.read_text()
old = re.search(r'^version = "([^"]+)"', text, re.M).group(1)
major, minor, patch = map(int, old.split("."))
new = f"{major}.{minor + 1}.0"
py.write_text(text.replace(f'version = "{old}"', f'version = "{new}"'))
(root / "src/expenses/__init__.py").write_text(f'"""Expense reports."""\n\n__version__ = "{new}"\n')

changelog = root / "CHANGELOG.md"
changelog.write_text(f"# Changelog\n\n## {new}\n\n- Add CSV export command\n\n## {old}\n\n- Initial release\n")
print(old, "->", new)
print(changelog.read_text().splitlines()[2])
Output
0.1.0 -> 0.2.0
## 0.2.0
# release.sh
git add pyproject.toml src/expenses/__init__.py CHANGELOG.md
git commit -m "Release 0.2.0"
git tag v0.2.0
uv build
# practice on TestPyPI first (token from your TestPyPI account, scoped to this project)
UV_PUBLISH_TOKEN="$TEST_PYPI_TOKEN" uv publish --publish-url https://test.pypi.org/legacy/
pip install --index-url https://test.pypi.org/simple/ --no-deps expenses-cli==0.2.0
# then the real index
UV_PUBLISH_TOKEN="$PYPI_TOKEN" uv publish

Try it yourself

Build the expense project with uv build, list the wheel's contents with unzip -l, and install it into a fresh virtual environment to run the command. Bump the version to 0.2.0, add a changelog entry, tag the commit, and rebuild. Create a TestPyPI account and an API token, publish with uv publish --publish-url https://test.pypi.org/legacy/, and install your package from TestPyPI into yet another fresh environment. Do not publish to the real PyPI until the package is something you want to maintain.

Common mistakes

What's next

Your code can be installed anywhere. Next you will make it understandable to the people who install it: documenting code with docstrings, doctests, and a generated site.

Course overview