Code of the Day
AdvancedPublishing to PyPI

Lab: Publish to TestPyPI

Take a click CLI tool from intermediate, prepare it for release, and publish it to TestPyPI.

Lab · optionalUtilitiesAdvanced30 min
Recommended first
By the end of this lesson you will be able to:
  • 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 table

The 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."""
    pass

Running 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 — the description field 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 --help

The --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.

Finished reading? Mark it complete to track your progress.

On this page