Project Setup

Managing CLI Versioning & Changelogs

Manage Python CLI versioning with semantic versioning, bump-my-version, and automated CHANGELOG generation using Conventional Commits and git-cliff.

Updated

Every CLI a human installs eventually answers one question first: "what version is this?" Get versioning wrong and you ship a tool whose --version lies, a CHANGELOG nobody trusts, and a release process held together by manual find-and-replace. This article shows the modern, single-sourced approach: define the version once in pyproject.toml, read it at runtime with importlib.metadata, bump it with bump-my-version, and generate the changelog automatically from Conventional Commits using git-cliff.

TL;DR

  • Single-source the version: put it only in pyproject.toml; read it at runtime with from importlib.metadata import version.
  • Follow SemVer (MAJOR.MINOR.PATCH) — for CLIs the "public API" is your flags, subcommands, output format, and exit codes.
  • Bump consistently with bump-my-version bump patch|minor|major, which rewrites the version and tags the commit.
  • Automate the changelog: write Conventional Commits, then run git cliff -o CHANGELOG.md to generate it from history.

Diagram: one version in pyproject.toml flows through the build to package metadata, is read at runtime by importlib.metadata.version() for mytool --version, while Conventional Commits feed git-cliff to produce CHANGELOG.md.

Semantic versioning for a CLI

Semantic Versioning reads MAJOR.MINOR.PATCH. The discipline is in knowing what counts as a breaking change for a command-line tool — it is not just importable Python symbols. Your public contract is everything a user or a script can depend on:

  • MAJOR — you removed or renamed a flag/subcommand, changed a default, altered machine-readable output (JSON schema, column order), or changed exit codes. Anything that breaks an existing invocation.
  • MINOR — you added a new subcommand, a new optional flag, or new output fields, in a backward-compatible way.
  • PATCH — bug fixes that don't change the documented behavior.

The implication: treat --help text loosely, but treat flag names, exit codes, and the shape of any --json/--output payload as a versioned API. Scripts in someone's CI pipeline parse that output; renaming a JSON key is a major bump even though the code "still works."

Single-sourcing the version

The cardinal rule is one source of truth. Declare the version in pyproject.toml and nowhere else — no hardcoded __version__ = "1.2.0" string that drifts out of sync with the package metadata. At runtime, read it back from the installed distribution's metadata with importlib.metadata.

Here's the package metadata in pyproject.toml (PEP 621):

[project]
name = "mytool"
version = "0.3.0"
description = "An example CLI"
requires-python = ">=3.10"
dependencies = ["typer>=0.12"]

[project.scripts]
mytool = "mytool.cli:app"

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

Now read that single value at runtime. The modern pattern (Python 3.8+, no third-party dependency) uses importlib.metadata.version with a PackageNotFoundError fallback so the tool still runs from a source checkout that was never pip installed:

# mytool/_version.py
from importlib.metadata import version, PackageNotFoundError

__all__ = ["__version__"]

try:
    # Resolves the version recorded in installed metadata at runtime.
    __version__ = version("mytool")
except PackageNotFoundError:
    # Running from a source checkout that was never installed.
    __version__ = "0.0.0+unknown"

This is validated below. With the package installed, version("mytool") returns exactly what's in pyproject.toml; from a bare checkout it falls back gracefully instead of crashing.

Note the distribution name ("mytool", matching [project].name) may differ from the import package name. Pass the distribution name to version().

Exposing --version

Wire __version__ into your entry point. In Typer, use an eager callback so --version short-circuits before any command runs:

# mytool/cli.py
import typer
from mytool._version import __version__

app = typer.Typer()


def _version_callback(value: bool) -> None:
    if value:
        typer.echo(f"mytool {__version__}")
        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:
    """mytool — an example CLI."""

The Click equivalent is the built-in decorator, which reads metadata for you:

import click
from mytool._version import __version__

@click.group()
@click.version_option(version=__version__, prog_name="mytool")
def cli() -> None:
    ...

For more on structuring the callable behind [project.scripts], see best practices for Python CLI entry points.

Bumping the version with bump-my-version

Editing pyproject.toml by hand on every release invites typos and forgotten git tags. bump-my-version (the maintained successor to bump2version) automates the rewrite, the commit, and the tag in one command. Configure it inside pyproject.toml:

[tool.bumpversion]
current_version = "0.3.0"
allow_dirty = false
commit = true
message = "chore(release): bump version {current_version} -> {new_version}"
tag = true
tag_name = "v{new_version}"
tag_message = "Release v{new_version}"

[[tool.bumpversion.files]]
filename = "pyproject.toml"
search = 'version = "{current_version}"'
replace = 'version = "{new_version}"'

Because the version lives only in pyproject.toml, that single [[tool.bumpversion.files]] block is all you need — no second file to keep in sync. Driving it:

# Install once (kept out of your runtime deps; a dev/tool dependency):
uv tool install bump-my-version

# Preview without writing anything:
bump-my-version bump --dry-run --verbose patch

# 0.3.0 -> 0.3.1, commit, and tag v0.3.1:
bump-my-version bump patch

# 0.3.1 -> 0.4.0:
bump-my-version bump minor

# 0.4.0 -> 1.0.0:
bump-my-version bump major

git push --follow-tags

The --dry-run --verbose preview is your safety net: it prints the exact diff and the tag it would create before you commit to anything.

Conventional Commits

Automated changelogs need machine-readable history. Conventional Commits is a lightweight convention for commit subjects: <type>(<scope>): <description>, with a ! or BREAKING CHANGE: footer for incompatible changes.

feat(parser): add --json output to the list command
fix(auth): respect HTTPS_PROXY when refreshing tokens
docs(readme): document the new --json flag
refactor(core): extract retry logic into a helper
feat(api)!: rename --target to --destination   # breaking -> MAJOR

The payoff is twofold. First, the types map directly onto SemVer: fix → PATCH, feat → MINOR, anything with !/BREAKING CHANGE → MAJOR. Second, a tool can group these into changelog sections without you writing release notes by hand. Enforce the format in CI or with a pre-commit hook so a single non-conforming commit doesn't poison the generated log.

Automated changelogs with git-cliff

git-cliff parses your Conventional Commits and renders a CHANGELOG.md. It's a single Rust binary configured by a cliff.toml:

[changelog]
header = "# Changelog\n\nAll notable changes to this project are documented here.\n"
body = """
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits %}
- {{ commit.message | upper_first }}\
{% endfor %}
{% endfor %}
"""
trim = true

[git]
conventional_commits = true
filter_unconventional = true
commit_parsers = [
  { message = "^feat", group = "Features" },
  { message = "^fix", group = "Bug Fixes" },
  { message = "^docs", group = "Documentation" },
  { message = "^perf", group = "Performance" },
  { message = "^refactor", group = "Refactor" },
  { body = ".*security", group = "Security" },
  { message = "^chore\\(release\\)", skip = true },
  { message = "^chore", skip = true },
]
tag_pattern = "v[0-9]*"

Note the commit_parsers: chore(release) commits (the ones bump-my-version creates) are skipped so your changelog isn't cluttered with "bump version" noise. Generate and regenerate it:

uv tool install git-cliff

# Render the full history into CHANGELOG.md:
git cliff -o CHANGELOG.md

# Only the unreleased commits, e.g. to paste into a draft release:
git cliff --unreleased

# Render notes for a specific tag range (handy in CI on tag push):
git cliff v0.3.0..v0.4.0

The clean release flow combines all three tools: write Conventional Commits as you work, run git cliff -o CHANGELOG.md and commit it, then bump-my-version bump minor to tag the release. In CI, you can run git-cliff on tag push to auto-attach release notes.

Why this works (and the trade-offs)

Single-sourcing removes the most common bug class entirely: a --version that disagrees with the published package. The version exists in exactly one place, the build backend stamps it into the wheel metadata, and importlib.metadata reads it back. There is nothing to keep in sync.

The trade-off is the PackageNotFoundError fallback. When you run straight from a git clone without installing, no distribution metadata exists, so version() raises. The fallback string ("0.0.0+unknown") keeps the CLI usable, but it means "version from a dev checkout is meaningless" — which is correct. If you want a real version in editable installs, pip install -e . / uv pip install -e . writes metadata, and importlib.metadata resolves it normally.

Conventional Commits asks for discipline from contributors. The win is that the discipline is enforceable and useful — it drives both the changelog and the SemVer decision, so it isn't bureaucracy for its own sake.

Production notes

  • Tag and version must agree. bump-my-version's tag_name = "v{new_version}" guarantees the git tag matches pyproject.toml. CI that builds on tag push should assert this before publishing.
  • Don't read the version with regex. Parsing pyproject.toml at runtime to extract the version is fragile and adds a startup file read. importlib.metadata is the supported path and works from the installed wheel.
  • Performance. importlib.metadata.version() does a small metadata lookup on each call. For a CLI it's negligible, but call it once at import time (as in _version.py) rather than per-command.
  • uv and Poetry both honor the same metadata. Whether you manage deps with uv or Poetry, the version lives in pyproject.toml and importlib.metadata reads it identically — the runtime pattern doesn't change.
  • Pre-1.0. Under SemVer, 0.x lets you break things in MINOR bumps. State this in your README so users know 0.x is unstable by design.