--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 --jsonfor 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
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
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"
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.