Lab: Publish to TestPyPI
Take a click CLI tool from intermediate, prepare it for release, and publish it to TestPyPI.
- Prepare a pyproject.toml for a real release
- Write a first CHANGELOG entry
- Build, twine check, and publish to TestPyPI
- Install from TestPyPI and verify the tool works
This lab takes the my-tool click application from the intermediate track and
walks it through a complete first release. By the end you will have published
a real package to TestPyPI and installed it in a fresh virtual environment.
You need a free TestPyPI account for the upload steps. Register at
https://test.pypi.org/account/register/ and create an API token in your
account settings. Store it as TWINE_PASSWORD and set
TWINE_USERNAME=__token__.
Step 1 — Review and complete pyproject.toml
Start with the structure from the build backends lesson. The key fields that
beginners often leave incomplete are readme, requires-python, and
classifiers. A minimal but complete file:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "my-tool-<your-username>"
version = "0.1.0"
description = "A CLI utility for processing text files."
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
authors = [
{ name = "Your Name", email = "you@example.com" },
]
keywords = ["cli", "text-processing"]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Environment :: Console",
"Development Status :: 3 - Alpha",
]
dependencies = [
"click>=8.1",
"rich>=13.0",
]
[project.scripts]
my-tool = "my_tool.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["src/my_tool"]The name must be unique on TestPyPI, so append your username or a random suffix.
The Development Status :: 3 - Alpha classifier signals that this is a first
release with an unstable API.
Step 2 — Write a README
PyPI renders the README on the package page. A minimal README that passes
twine check:
# my-tool
A CLI utility for processing text files.
## Installation
pip install my-tool-<your-username>
## Usage
my-tool scan ./data --extension .log
my-tool report --format tableThe file must exist at the path named in pyproject.toml (readme = "README.md").
twine check will report a missing or unrenderable README before it reaches PyPI.
Step 3 — Add a CHANGELOG
Create CHANGELOG.md at the project root:
# Changelog
## [Unreleased]
## [0.1.0] - 2025-06-01
### Added
- Initial release with `scan` and `report` subcommands.
- Progress bar during file scanning.
- Table and plain-text output formats.A CHANGELOG from the very first release signals project hygiene to future contributors. Fill in the date and adjust the feature list to match what you actually built.
Step 4 — Expose version
Add version introspection to src/my_tool/__init__.py:
from importlib.metadata import version, PackageNotFoundError
try:
__version__ = version("my-tool-<your-username>")
except PackageNotFoundError:
__version__ = "0.0.0+dev"Wire it into the click entry point:
import click
from my_tool import __version__
@click.group()
@click.version_option(version=__version__, prog_name="my-tool")
def main():
"""CLI utility for processing text files."""
passRunning my-tool --version will now print the installed version without any
hardcoded string in the Python source.
Step 5 — Build and check
# Install the build tools if not already present
pip install build twine
# Clean any previous dist/ artifacts
rm -rf dist/
# Build wheel and sdist
python -m build
# Verify the artifacts
twine check dist/*A clean twine check reports PASSED for each file. Common failures:
The description failed to render— your README has invalid Markdown or the README path is wrong.Missing required metadata: description— thedescriptionfield is absent from[project].Invalid classifier— a typo in a classifier string; they must match PyPI's classifier taxonomy exactly.
Fix any reported issue and re-run python -m build before proceeding.
Step 6 — Upload to TestPyPI
twine upload --repository testpypi dist/*Twine prompts for credentials if TWINE_PASSWORD is not set. Use
__token__ as the username and your TestPyPI API token as the password.
After a successful upload, your package appears at
https://test.pypi.org/project/my-tool-<your-username>/.
Step 7 — Install and verify
Create a fresh virtual environment and install from TestPyPI:
python -m venv /tmp/test-install-env
source /tmp/test-install-env/bin/activate
pip install \
-i https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ \
my-tool-<your-username>==0.1.0
# Verify
my-tool --version
my-tool --helpThe --extra-index-url flag allows pip to fetch click and rich from real
PyPI since they will not be on TestPyPI. Once the tool runs correctly, the
release is confirmed.
What you practised
You walked through every step a maintainer runs before touching the real PyPI:
reviewing metadata, writing a README, tracking changes in a CHANGELOG, exposing
a single-source version, building and validating artifacts, uploading to the
sandbox, and confirming the install. The only difference between this and a
production release is swapping --repository testpypi for the default (real
PyPI) in the final upload.
Where to go next
Next module: performance and streaming — profiling memory usage and converting buffer-everything code to generator pipelines that handle arbitrarily large input.