In part 1 we built taskcli’s command structure with Typer. In part 2 we added persistent storage and a pyproject.toml that makes pip install -e . work locally. The last step is making taskcli installable by anyone, anywhere, with a plain pip install taskcli. That means publishing to PyPI, the Python Package Index.
What “publishing” actually means
PyPI hosts two kinds of build artifacts for every release:
- A wheel (
.whl), a prebuilt, ready-to-install package.pipprefers this when one is available for the user’s platform. - A source distribution or sdist (
.tar.gz), the raw source plus enough metadata to build a wheel from scratch.pipfalls back to this if no matching wheel exists.
For a pure-Python tool like taskcli (no compiled extensions), a single wheel works on every platform. Publishing is: build both artifacts, then upload them.
Picking a version
Before building anything, decide on taskcli’s version. Follow semantic versioning: MAJOR.MINOR.PATCH. Bump PATCH for bug fixes, MINOR for backward-compatible features, MAJOR for breaking changes. taskcli is currently 0.1.0 in pyproject.toml from part 2, which is the conventional starting point for a project that isn’t API-stable yet.
The version lives in exactly one place: the version field in pyproject.toml. Resist the temptation to also hardcode it in cli.py for a --version flag, that guarantees the two will eventually drift.
Real-world pattern: a --version flag that can’t drift
Read the version back from the installed package’s metadata instead of duplicating it:
# src/taskcli/cli.py
from importlib.metadata import version as _pkg_version
import typer
app = typer.Typer(help="A simple command-line task tracker.")
def _version_callback(value: bool) -> None:
if value:
typer.echo(f"taskcli {_pkg_version('taskcli')}")
raise typer.Exit()
@app.callback()
def main(
version: bool = typer.Option(
None, "--version", callback=_version_callback, is_eager=True, help="Show the version and exit."
),
) -> None:
pass
taskcli --version
# taskcli 0.1.0
importlib.metadata.version reads whatever was recorded at install time from pyproject.toml, so there’s exactly one source of truth. is_eager=True makes Typer process --version before validating other arguments, matching how --help behaves.
Building the distributions
Install the build package (a thin, standardized wrapper around whatever backend your pyproject.toml declares, hatchling in this case):
pip install build
python -m build
This produces:
dist/
├── taskcli-0.1.0-py3-none-any.whl
└── taskcli-0.1.0.tar.gz
py3-none-any in the wheel filename means: works on any Python 3 interpreter, no ABI constraint, no platform constraint. That’s what a pure-Python package should produce. If you ever see a platform-specific tag like cp311-cp311-manylinux..., it means something in the build (usually a C extension) is tying the wheel to a specific Python version and OS.
Always build into a clean dist/ directory. Stale artifacts from a previous version left in dist/ get uploaded right alongside the new ones if you’re not careful, which is confusing at best.
Checking the build before uploading
twine, the standard upload tool, also validates a build’s metadata without needing network access:
pip install twine
twine check dist/*
This catches common mistakes early: a README.md that won’t render as long_description on PyPI, missing required metadata, or a malformed license field. It’s free, so run it every time.
Dry run: TestPyPI
TestPyPI is a separate instance of PyPI meant exactly for this: uploading a release to see if it works before it’s permanent. Package names and versions on PyPI cannot be reused once published, even if you delete the release, so a live rehearsal here is worth the extra step.
Register a TestPyPI account (separate from a regular PyPI account) and generate an API token from the account settings page, then:
twine upload --repository testpypi dist/*
twine prompts for credentials; using an API token as the password (with __token__ as the username) avoids ever putting an account password in shell history. Verify the install works end to end in a throwaway environment:
python -m venv /tmp/verify-env
source /tmp/verify-env/bin/activate
pip install --index-url https://test.pypi.org/simple/ --no-deps taskcli
taskcli --version
--no-deps matters here: typer and platformdirs likely aren’t published on TestPyPI, only on real PyPI, so a normal dependency resolution would fail.
Publishing for real
Once the TestPyPI install checks out, do the same upload against the real index:
twine upload dist/*
Use an API token scoped to just this project (PyPI lets you create project-scoped tokens after the first upload), not your account-wide token, so a leaked credential can’t touch your other packages.
Real-world pattern: a .pypirc for repeated uploads
Typing credentials on every twine upload gets old fast. Store them in ~/.pypirc instead:
# ~/.pypirc
[distutils]
index-servers =
pypi
testpypi
[pypi]
username = __token__
password = pypi-AgEIcHlwaS5vcmc...
[testpypi]
repository = https://test.pypi.org/legacy/
username = __token__
password = pypi-AgENdGVzdC5weXBp...
With this in place, twine upload dist/* and twine upload --repository testpypi dist/* need no further prompts. Set the file’s permissions to 600 (chmod 600 ~/.pypirc) since it holds live credentials in plain text, and never commit it to a repo.
Real-world pattern: publishing from CI with trusted publishing
Storing a long-lived PyPI token as a GitHub Actions secret works, but it’s a standing credential that keeps working even if the repo’s permissions change. PyPI’s trusted publishing avoids that entirely: PyPI trusts a specific GitHub workflow (identified by repo, workflow filename, and environment) via OpenID Connect, and issues a short-lived token for that single run. No secret to store, rotate, or leak.
Configure it once on the PyPI project page (Publishing settings, add a GitHub publisher, pointing at the repo and workflow file below), then:
# .github/workflows/publish.yml
name: Publish to PyPI
on:
release:
types: [published]
jobs:
publish:
runs-on: ubuntu-latest
environment: release
permissions:
id-token: write # required for trusted publishing
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install build
- run: python -m build
- uses: pypa/gh-action-pypi-publish@release/v1
The id-token: write permission is what lets the workflow request an OIDC token from GitHub that PyPI can verify. With this workflow, cutting a release is: bump the version in pyproject.toml, tag it, push, and publish a GitHub release, everything else happens automatically.
Tagging releases
Once a version is published, tag the commit it was built from so the release is traceable back to exact source:
git tag -a v0.1.0 -m "Release 0.1.0"
git push origin v0.1.0
If publishing is automated via the workflow above, pushing a tag and creating the matching GitHub release is what triggers the on: release: published event, tying the whole chain together: tag pushed, release published, CI builds, PyPI receives the upload.
Verifying the real release
After the upload finishes, PyPI takes effect immediately (unlike some registries there’s no propagation delay to worry about):
python -m venv /tmp/verify-real
source /tmp/verify-real/bin/activate
pip install taskcli
taskcli add "it's really on PyPI"
taskcli list
# [1] it's really on PyPI
The series, end to end
Three parts ago, taskcli was a single function that printed to the screen and forgot everything the moment it exited. It’s now a real piece of software with a proper shape:
- Part 1 gave it structure: a
src/layout, subcommands built from type hints with Typer, exit codes that scripts can rely on, and tests that run in-process instead of shelling out. - Part 2 gave it memory: task data lives in the correct OS-specific directory via
platformdirs, writes are atomic so a crash can’t corrupt the file, andpyproject.tomlturns the project into an installable package with a realtaskclicommand. - Part 3 (this one) gave it an audience: a versioning scheme that doesn’t drift, reproducible builds, a rehearsal on TestPyPI, and a release pipeline that publishes to PyPI without a stored secret in sight.
None of these steps are specific to a task tracker. The same shape, Typer for the interface, a storage module that isolates the filesystem, pyproject.toml plus build and twine for distribution, applies to any CLI tool you write next. The scaffolding is the reusable part; taskcli was just the excuse to build it.
You now have everything you need to take an idea from a single script to something someone else can pip install and use. Go forth and build some software.