Versioning
Apply SemVer to CLI tools, expose __version__ via importlib.metadata, and understand pre-release suffixes.
- Apply SemVer (major.minor.patch) to decide when to bump each component
- Expose __version__ from importlib.metadata as the single source of truth
- Understand pre-release and post-release version suffixes
Version numbers are a contract with your users. Breaking that contract — shipping incompatible changes in a patch release, or holding back a working feature in a major bump — erodes trust. SemVer gives both you and your users a shared vocabulary for what a release means.
SemVer in three sentences
Major.Minor.Patch (1.4.2):
- Patch (
1.4.2 → 1.4.3): a bug is fixed, no public API changes. Users can upgrade without reading the changelog. - Minor (
1.4.x → 1.5.0): new behaviour or flags are added, but existing invocations still work as before. Users may want the new feature; they do not need it. - Major (
1.x.y → 2.0.0): a breaking change. A flag was renamed, a default changed, or behaviour that users rely on was removed. Existing scripts may stop working.
For a CLI tool, breaking changes include: renaming a subcommand, removing a flag, changing the format of stdout output (if callers parse it), or changing an exit code. Adding a new subcommand or flag is a minor change.
Single source of truth with importlib.metadata
The most common versioning mistake is duplicating the version string: once in
pyproject.toml, once in __init__.py, sometimes again in a --version flag.
They drift.
The correct approach: declare the version exactly once in pyproject.toml and
read it everywhere else:
from importlib.metadata import version, PackageNotFoundError
try:
__version__ = version("my-tool")
except PackageNotFoundError:
# Running from the source tree without installation
__version__ = "0.0.0+dev"Place this in src/my_tool/__init__.py. Your click --version flag can then
reference it:
import click
from my_tool import __version__
@click.command()
@click.version_option(version=__version__, prog_name="my-tool")
def main():
...importlib.metadata.version() reads the version from the installed package
metadata — the same string that pip stores after pip install. There is no
synchronisation step because there is no duplication.
Pre-release and post-release suffixes
PEP 440 formalises version ordering. Common suffixes:
| Suffix | Meaning | Example |
|---|---|---|
a | Alpha — feature-incomplete | 1.2.0a1 |
b | Beta — feature-complete, may have bugs | 1.2.0b2 |
rc | Release candidate — intended release | 1.2.0rc1 |
.post | Post-release — doc or packaging fix only | 1.2.0.post1 |
.dev | Development build — never publish publicly | 1.2.0.dev20250601 |
Pre-release versions are not installed by default: pip install my-tool skips
1.2.0rc1 and stays on 1.1.0. Users who want the pre-release must opt in with
pip install my-tool==1.2.0rc1 or pip install --pre my-tool.
Start at 0.1.0, not 1.0.0. A 0.x version signals that the public API is
not yet stable — breaking changes are expected. Move to 1.0.0 when you are
ready to commit to backwards compatibility.
Where to go next
Next: release workflow — bumping the version, building artifacts, checking
them with twine, and publishing to TestPyPI step by step.