What you'll learn
- Build a source distribution and a wheel from
pyproject.toml - Understand what is inside a wheel and how
pip installuses it - Version a release with semantic versioning and a changelog
- Publish to TestPyPI first, then PyPI, with a token, never a password
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.

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.

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()))
['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
expenses_cli-0.1.0-py3-none-any.whl
expenses_cli-0.1.0.tar.gzInside 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())
['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)"
expenses 0.1.0
site-packagesVersioning 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])
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
- Publishing without installing the wheel first. A missing file or a broken entry point is found by a user instead of by you; always install into a fresh venv and run it.
- Reusing a version number. Indexes refuse it, and users cannot tell two builds apart; bump and tag every release.
- A token in a file. Tokens belong in environment variables or CI secrets; a committed token is compromised the moment it is pushed.
What's next
uv buildproduces an sdist and a wheel indist/; the wheel holds your package plusMETADATA,RECORD, andentry_points.txt.- Verify by installing the wheel into a fresh environment and running the command; version with
MAJOR.MINOR.PATCH, a changelog, and a Git tag. - Publish to TestPyPI first, then PyPI, authenticating with a scoped API token from the environment.
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.