Project Setup

Deriving CLI Versions from Git Tags with hatch-vcs

Stop editing version numbers by hand: derive a Python CLI’s version from git tags with hatch-vcs, read it at runtime, and handle dev builds, CI clones and sdists.

Updated

A version number that lives in pyproject.toml has to be edited by hand before every release, and sooner or later someone tags v1.6.0 on a commit whose pyproject.toml still says 1.5.0. The package on PyPI then disagrees with the tag, mytool --version prints the wrong thing, and because PyPI never allows a version to be re-uploaded, the fix is another release. Deriving the version from the git tag removes the second copy entirely: the tag is the version, read by the build backend at build time and by your CLI at runtime from the installed metadata. This guide sets that up with hatch-vcs, explains the development versions it produces between releases, and covers the three places it commonly goes wrong — shallow CI clones, source distributions and editable installs. It belongs to the managing CLI versioning and changelogs topic.

Prerequisites

  • A CLI built with the hatchling backend (uv's default for uv init --package). Projects on setuptools can use setuptools-scm with nearly identical behaviour.
  • Releases marked with annotated git tags such as v1.5.0.
  • git available wherever the package is built.

How the version flows

Where the version comes from A git tag is read by hatch-vcs at build time, written into the package metadata, and read at runtime with importlib.metadata to print the version. Where the version comes from git tag v1.5.0 the source of truth hatch-vcs at build time Wheel metadata Version: 1.5.0 mytool --version importlib.metadata describe writes reads The version appears in exactly one place a human edits: the tag.

At build time, hatch-vcs asks git to describe the current commit relative to the most recent tag, turns the answer into a PEP 440 version, and hands it to hatchling, which writes it into the wheel's metadata. At runtime your CLI reads the version back from that metadata with importlib.metadata. Nothing in the repository contains the version number — only the tag.

The recipe

# pyproject.toml
[project]
name = "mytool"
dynamic = ["version"]              # no version = "..." line
requires-python = ">=3.10"
dependencies = ["typer>=0.12"]

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

[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"

[tool.hatch.version]
source = "vcs"

[tool.hatch.build.hooks.vcs]
version-file = "src/mytool/_version.py"   # optional; see below

dynamic = ["version"] tells build tools that the version is computed. source = "vcs" selects hatch-vcs. The optional build hook writes a _version.py module into the package at build time — add it to .gitignore, since it is generated.

At runtime, read the version from the installed distribution:

# src/mytool/cli.py
from importlib.metadata import PackageNotFoundError, version
from typing import Annotated

import typer

try:
    __version__ = version("mytool")
except PackageNotFoundError:          # running from a checkout that was never installed
    __version__ = "0+unknown"

app = typer.Typer()


def _show_version(value: bool) -> None:
    if value:
        typer.echo(f"mytool {__version__}")
        raise typer.Exit()


@app.callback()
def main(
    _: Annotated[bool, typer.Option("--version", is_eager=True, callback=_show_version,
                                    help="Show the version and exit.")] = False,
) -> None:
    """mytool — deployment helpers."""

importlib.metadata.version() is the most reliable source: it reads the metadata of whatever is actually installed, so it is right for wheels, sdists, editable installs and frozen builds alike. The generated _version.py is useful when something needs the version without package metadata — a PyInstaller binary built from a source tree, for example — but for a normally installed CLI, metadata is enough. More on presenting version information is in exposing version info and build metadata.

What version does a given checkout produce?

On a tagged commit, the version is the tag minus its v. Between tags, hatch-vcs produces a development version that sorts after the last release and before the next one, and records exactly which commit it came from:

What version does this checkout produce? Versions computed by hatch-vcs from git state: an exact tag, commits after a tag, uncommitted changes, and no tags at all. What version does this checkout produce? Git state Version produced On tag v1.5.0, clean 1.5.0 3 commits after v1.5.0 1.5.1.dev3+g1a2b3c4 Uncommitted changes 1.5.1.dev3+g1a2b3c4.d20260918 No tags / shallow clone 0.1.dev1+g... (fallback) The last row is why CI checkouts need fetch-depth: 0.
  • 1.5.0 — exactly on tag v1.5.0, clean working tree.
  • 1.5.1.dev3+g1a2b3c4 — three commits after v1.5.0; the next patch is assumed, dev3 counts commits, and g1a2b3c4 is the commit hash.
  • ...+g1a2b3c4.d20260918 — the same, with uncommitted changes; the date marks a dirty tree.
  • 0.1.dev1+g... — no tag reachable, usually because of a shallow clone.

Development versions are valid PEP 440 versions, so mytool --version on a developer's machine tells you precisely which commit they are running — useful in bug reports. PyPI rejects versions with a +local part, which is a helpful safety net: a build from an untagged commit cannot be published by accident.

Where it goes wrong, and the fixes

Shallow clones in CI. actions/checkout fetches one commit by default, with no tags, so hatch-vcs falls back to 0.1.dev1. Fetch full history in any job that builds the package:

- uses: actions/checkout@v4
  with:
    fetch-depth: 0

Building from an sdist without git. A source distribution is not a git repository. hatch-vcs handles this by writing the version into the sdist's PKG-INFO when the sdist is built, and reading it back when building a wheel from the sdist — which is why you should build the sdist in an environment that does have git history, and let downstream packagers build wheels from it.

Editable installs go stale. uv sync installs your project in editable mode, and the version in its metadata is computed when the environment is created. After tagging, mytool --version in the dev environment may still show the old dev version until you reinstall with uv sync --reinstall-package mytool. It is cosmetic, but it confuses people exactly once.

Building outside a repository. Docker builds that COPY only src/ and pyproject.toml have no .git directory. Either build the wheel before the Docker build and install the wheel, or pass the version in with the SETUPTOOLS_SCM_PRETEND_VERSION environment variable, which hatch-vcs honours: ENV SETUPTOOLS_SCM_PRETEND_VERSION=1.5.0.

UX considerations

  • Keep --version output simple. One line, mytool 1.5.0, parseable by scripts. Development builds show their full version, which is exactly what a bug report needs.
  • Make release tagging a one-liner. With the version coming from the tag, a release is git tag -a v1.6.0 -m "mytool 1.6.0" && git push origin v1.6.0. The CI side is in automating releases from git tags.
  • Check before you push the tag. uvx hatch version prints what the build would produce; if it is not the version you expect, the tag is missing, mistyped or on the wrong commit.
Checking the computed version Terminal output of checking the version hatch-vcs computes before tagging and after tagging, and of the installed tool reporting it. Checking the computed version bash $ uvx hatch version 1.5.1.dev3+g1a2b3c4 $ git tag -a v1.6.0 -m "mytool 1.6.0" && uvx hatch version 1.6.0 $ uv run mytool --version mytool 1.6.0 Check the version before pushing the tag; a surprise here means a missing or mistyped tag.

Testing the behaviour

Two tests are worth having. A unit test that --version prints something version-shaped, and a slower test that builds from a temporary repository with a tag and checks the result — the kind of test that catches a broken build configuration before a release does:

# tests/test_version.py
import os
import re
import subprocess
import sys
from pathlib import Path

import pytest
from typer.testing import CliRunner

from mytool.cli import app

VERSION = re.compile(r"^mytool \d+\.\d+\.\d+(\.dev\d+)?(\+[\w.]+)?$")


def test_version_flag():
    result = CliRunner().invoke(app, ["--version"])
    assert result.exit_code == 0
    assert VERSION.match(result.output.strip()), result.output


@pytest.mark.slow
def test_tag_becomes_version(tmp_path: Path):
    repo = Path(__file__).parent.parent
    subprocess.run(["git", "clone", "-q", str(repo), str(tmp_path / "r")], check=True)
    r = tmp_path / "r"
    env = {**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@e",
           "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@e"}
    subprocess.run(["git", "tag", "-a", "v9.8.7", "-m", "test"], cwd=r, check=True, env=env)
    subprocess.run([sys.executable, "-m", "build", "--wheel", "--outdir", str(tmp_path / "d")],
                   cwd=r, check=True, capture_output=True)
    assert list((tmp_path / "d").glob("mytool-9.8.7-*.whl"))

The second test clones the repository (so it has history and can take a throwaway tag), tags it, builds, and asserts that the wheel carries exactly the tagged version.

Conclusion

A version number should exist in one place, and for a project released from git that place is the tag. hatch-vcs reads it at build time and writes it into the package metadata; importlib.metadata.version() reads it back at runtime; development builds get precise, unpublishable dev versions for free. Fetch full history in CI, build sdists where git is available, remember to reinstall editable environments after tagging, and releasing becomes a single git tag with no file to forget.

Frequently asked questions

hatch-vcs or setuptools-scm?

They share an engine: hatch-vcs is the hatchling plugin built on setuptools-scm's version logic, so versions come out identical. Use whichever matches your build backend.

Can I use tags without the v prefix?

Yes; both v1.5.0 and 1.5.0 are recognised by default. Pick one convention and stick to it, since mixing them makes history confusing and can confuse other tooling.

How do I make the next dev version a minor rather than a patch bump?

Set [tool.hatch.version.raw-options] version_scheme = "release-branch-semver" or another setuptools-scm scheme. The default guesses the next patch, which is fine — dev versions only need to sort correctly, not predict the future.

How does this work in a repository with several packages?

Each package needs its own tag namespace, such as cli-v1.5.0 and core-v2.1.0. Configure each package's tag-pattern (via [tool.hatch.version.raw-options]) to match only its own tags, and set root to the repository root so git is found from the package's subdirectory. The workspace layout that usually goes with this is described in uv workspaces for multi-package CLIs.

Should I still expose __version__ in the package?

It is a convention some users and tools still look for. Setting __version__ = version("mytool") in __init__.py, as the recipe does in the CLI module, costs one metadata lookup at import time and keeps both styles working. Avoid hard-coding it anywhere; that reintroduces the second copy this whole approach removes.

Does this work with uv's own build backend?

uv_build intentionally supports only static versions at the time of writing, so projects that want tag-derived versions keep hatchling with hatch-vcs as the backend. uv builds, installs and publishes them without any difference.