Code of the Day
AdvancedPublishing to PyPI

Versioning

Apply SemVer to CLI tools, expose __version__ via importlib.metadata, and understand pre-release suffixes.

UtilitiesAdvanced6 min read
By the end of this lesson you will be able to:
  • 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:

SuffixMeaningExample
aAlpha — feature-incomplete1.2.0a1
bBeta — feature-complete, may have bugs1.2.0b2
rcRelease candidate — intended release1.2.0rc1
.postPost-release — doc or packaging fix only1.2.0.post1
.devDevelopment build — never publish publicly1.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.

Finished reading? Mark it complete to track your progress.

On this page