Project Setup

Exposing Version Info and Build Metadata

Make --version useful - read the installed metadata, report the interpreter and install form, and give bug reports everything they need in three lines.

Updated

--version is the first line of every bug report. A bare number answers one question and leaves three unasked: which Python, installed how, and from where. Three lines of output turn a support round trip into a single paste.

TL;DR

  • Read the version from installed metadata, never a hard-coded string.
  • Print the Python version and interpreter path too — it explains half of all environment bugs.
  • Say how the tool was installed: pipx, source, or frozen.
  • Make the flag eager so it works when the rest of the command line is incomplete.
  • Offer --version --json for scripts that check compatibility.

Prerequisites

A packaged CLI with a version declared in pyproject.toml, and the single-source approach from managing CLI versioning and changelogs.

Reading the version from the right place

Where the version number comes from The version is declared once in project metadata or derived from a git tag, packaged into the distribution, and read back at run time from installed metadata. Where the version number comes from Tag or pyproject one source of truth Build baked into the wheel importlib.metadata read at run time --version cannot disagree declared installed printed A hard-coded __version__ alongside the metadata is the arrangement that eventually lies.
from importlib.metadata import PackageNotFoundError, version

def get_version() -> str:
    try:
        return version("mytool")
    except PackageNotFoundError:          # running from a source checkout
        return "0.0.0+dev"

This reads what was actually packaged, so the reported number cannot disagree with the wheel a user installed. The alternative — a __version__ constant maintained alongside the metadata — is the arrangement that eventually ships a --version that lies, usually right after a release where somebody bumped one and not the other.

The PackageNotFoundError branch matters during development, when the package may not be installed at all. Returning something clearly marked as a development build is better than crashing, and better than reporting a number that came from nowhere.

What to print

What --version should print Version output containing the tool version, the Python version and interpreter path, and the install form, enough to make a bug report actionable. What --version should print mytool --version the first line of every bug report mytool 1.4.0 read from installed metadata python 3.12.3 and the interpreter path install: pipx or frozen, or source Plain by default, richer with --verbose Never a hard-coded string Machine-readable under --json Three lines that remove an entire round trip from most support conversations.
import platform
import sys

def version_lines() -> list[str]:
    return [
        f"mytool {get_version()}",
        f"python {platform.python_version()} ({sys.executable})",
        f"install {install_form()}",
    ]

def install_form() -> str:
    if getattr(sys, "frozen", False):
        return "frozen"
    if "pipx" in sys.prefix or "uv/tools" in sys.prefix:
        return "isolated (pipx / uv tool)"
    if sys.prefix != sys.base_prefix:
        return "virtualenv"
    return "system"
Which build details are worth reporting A table of build and environment details a command line tool can include in version output, with the value of each for debugging. Which build details are worth reporting Detail Worth it? Why Package version always the first question anyone asks Python version + path always explains half of all environment bugs Install form usually pipx, frozen and source behave differently Git commit for pre-releases pins an exact build Build timestamp rarely breaks reproducible builds A timestamp makes two builds of the same commit differ, which is worth avoiding.

Three lines, and each answers a question that otherwise costs a message. The interpreter path is the one people underestimate: "I installed it but the command runs an old version" is nearly always two environments, and the path says so immediately.

What to leave out is a build timestamp. It makes two builds of the same commit differ, which undermines reproducible builds and adds nothing a version and a commit do not already say.

Making the flag behave

def version_callback(value: bool) -> None:
    if value:
        for line in version_lines():
            typer.echo(line)
        raise typer.Exit()

@app.callback()
def main(
    show_version: Annotated[
        bool,
        typer.Option("--version", callback=version_callback, is_eager=True,
                     help="Show version information and exit."),
    ] = False,
) -> None:
    ...

is_eager=True is the important part. Without it the flag is processed in declaration order, so a missing required argument raises a usage error before your callback runs — and a user trying to report which version they have gets an error about something else entirely.

Print to stdout, not stderr: version output is the result of the command the user asked for, and people pipe it into grep.

A machine-readable form

Scripts check versions too — an installer verifying a minimum, a CI step branching on capability. Give them something better than parsing a sentence:

@app.command()
def version(
    json_output: Annotated[bool, typer.Option("--json", help="Emit machine-readable output.")] = False,
) -> None:
    """Show version and environment information."""
    payload = {
        "version": get_version(),
        "python": platform.python_version(),
        "executable": sys.executable,
        "install": install_form(),
    }
    if json_output:
        typer.echo(json.dumps(payload))
        return
    for line in version_lines():
        typer.echo(line)

Having both a --version flag and a version command is normal and worth doing: the flag is what people type, and the command is where the richer output and the --json mode live.

Extending it into a doctor command

Once you are reporting the environment, a slightly fuller diagnostic command is a small addition and removes a large class of support question:

@app.command()
def doctor() -> None:
    """Print everything useful for a bug report."""
    for line in version_lines():
        typer.echo(line)
    typer.echo(f"config    {settings_path() or '(none found)'}")
    typer.echo(f"plugins   {', '.join(loaded_plugins()) or '(none)'}")
    typer.echo(f"colour    {'on' if presentation.colour else 'off'}")

Four more lines, and "why is it not using my config" and "why is my output plain" both become self-service. Point at it in your issue template — "please include the output of mytool doctor" — and most reports arrive complete.

Reporting what else is installed

For a tool with plugins or optional extras, the version alone is not the whole environment. Two additions cover the rest.

Which extras are present. A user reporting that a command is missing usually installed the base package without the extra it needs:

def installed_extras() -> list[str]:
    present = []
    for extra, module in {"analysis": "pandas", "aws": "boto3"}.items():
        if importlib.util.find_spec(module) is not None:
            present.append(extra)
    return present

find_spec checks availability without importing, so this costs nothing at start-up — which matters, because the whole point of an extra is that its import is expensive.

Which plugins loaded. For a host CLI, the list of discovered plugins and their versions answers "is this behaviour from the tool or from something I installed":

def loaded_plugins() -> list[str]:
    return [
        f"{ep.name} {version(ep.dist.name)}"
        for ep in entry_points(group="mytool.plugins")
        if ep.dist is not None
    ]

Both belong in the fuller doctor output rather than in --version, which should stay short. The division that works: --version is what people paste into a message, and doctor is what you ask for when the first three lines were not enough.

UX considerations

Keep the default output short. Three lines, plain text, no colour. This output is pasted into issue trackers and chat, where escape sequences render as noise.

Do not phone home. A version check that contacts a server on every invocation adds latency, fails offline, and surprises people. If you want to notify about upgrades, do it at most once a day, cache the result, and provide a way to switch it off.

Match what the package manager reports. If pipx list says 1.4.0 and your tool says 1.3.2, something is wrong — usually two installs. Reading the installed metadata is what keeps them in agreement.

Testing the behaviour

def test_version_reports_the_installed_version(cli):
    result = cli.invoke(app, ["--version"])

    assert result.exit_code == 0
    assert version("mytool") in result.stdout

def test_version_works_without_configuration(cli, tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)          # no config file anywhere
    assert cli.invoke(app, ["--version"]).exit_code == 0

def test_version_is_eager(cli):
    # --version must win even though 'sync' is missing its required argument
    result = cli.invoke(app, ["--version", "sync"])
    assert result.exit_code == 0

def test_json_version_is_parseable(cli):
    payload = json.loads(cli.invoke(app, ["version", "--json"]).stdout)
    assert payload["version"] == version("mytool")

The second and third are the ones that catch real regressions. A --version that needs a valid configuration file, or that loses to a usage error, is broken in exactly the situation where someone is trying to tell you what they are running.

Conclusion

Read the version from the installed metadata, print the interpreter alongside it, say how the tool was installed, and make the flag eager. Four small decisions, three lines of output, and most "works on my machine" conversations end before they start.

Frequently asked questions

Why not just hard-code __version__?

Because it can disagree with the packaged metadata, and eventually will. importlib.metadata reads what the installer recorded, which is what the user actually has. If you want a __version__ attribute for convenience, derive it from the metadata rather than the other way round.

Does reading metadata slow start-up?

Very slightly — a few milliseconds on first call, since it reads the distribution's metadata files. Call it lazily inside the version callback rather than at module import, and it costs nothing on every other invocation.

Should I include the git commit?

For pre-releases and development builds, yes: it pins an exact build when the version number cannot. For tagged releases it is redundant, and if you include it you need the build to know it, which usually means a build backend that derives the version from git anyway.

What if my tool is installed as a frozen binary?

sys.frozen is set, and importlib.metadata may not find the distribution inside the bundle — so bake the version in at build time as a fallback and report the install form as "frozen". Users of a frozen binary are the most likely to have lost track of where it came from.

Is -V worth adding as a short flag?

Yes, it is a widespread convention and costs one string in the option declaration. Avoid lowercase -v, which almost every tool uses for verbosity — the collision confuses more people than the shortcut helps.

Should the version output be stable enough to snapshot in tests?

Only the shape, not the values. The version number and interpreter path change legitimately between machines and releases, so pin the structure — three lines, each starting with a known label — and assert the actual version against importlib.metadata rather than against a literal.