From 83fb1d04daec57722db24f59506a0386026a162f Mon Sep 17 00:00:00 2001 From: Sargun Narula Date: Wed, 8 Apr 2026 14:18:04 +0530 Subject: [PATCH] Add --version / -V flag and did.version.get_version() Expose version via importlib.metadata when installed, else did.spec. Exit before config load so --version works without a config file; register flags for --help. Tests and README updated. Signed-Off-by: Sargun Narula Assisted-by: Claude --- README.rst | 1 + did/cli.py | 19 ++++++++++++++ did/version.py | 52 ++++++++++++++++++++++++++++++++++++++ tests/unit/test_cli.py | 19 ++++++++++++++ tests/unit/test_version.py | 12 +++++++++ 5 files changed, 103 insertions(+) create mode 100644 did/version.py create mode 100644 tests/unit/test_version.py diff --git a/README.rst b/README.rst index 349fa6d3..427a8d22 100644 --- a/README.rst +++ b/README.rst @@ -52,6 +52,7 @@ Gather stats for the last month:: did last month See ``did --help`` for complete list of available stats. +Use ``did --version`` (or ``-V``) to print the installed version. Options diff --git a/did/cli.py b/did/cli.py index 9f3149a9..f05079af 100644 --- a/did/cli.py +++ b/did/cli.py @@ -16,6 +16,7 @@ from did import utils from did.stats import UserStats from did.utils import log +from did.version import get_version USAGE = """ did [this|last] [week|month|quarter|year] [options] @@ -28,6 +29,15 @@ """.strip() +def _argv_list(arguments: Union[None, str, list[str]]) -> list[str]: + """Command-line tokens to parse (excluding program name).""" + if arguments is None: + return sys.argv[1:] + if isinstance(arguments, str): + return arguments.split() + return list(arguments) + + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Options # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -110,6 +120,10 @@ def __init__(self, arguments: Union[None, str, list[str]] = None): group.add_argument( "--test", action="store_true", help="Run a simple smoke test against the github server") + group.add_argument( + "--version", "-V", action="version", + version=f"did {get_version()}", + help="Show program version and exit") def _prepare_arguments(self, arguments: Union[None, str, list[str]]) -> None: """ Prepare arguments (both direct and from command line) """ @@ -209,6 +223,11 @@ def main(arguments: Union[None, str, list[str]] = None with the list of all gathered stats objects. """ + argv = _argv_list(arguments) + if "--version" in argv or "-V" in argv: + print(f"did {get_version()}") + raise SystemExit(0) + config = None try: config = did.base.Config() diff --git a/did/version.py b/did/version.py new file mode 100644 index 00000000..92136d1d --- /dev/null +++ b/did/version.py @@ -0,0 +1,52 @@ +""" +Version string for did. + +Uses :mod:`importlib.metadata` when the package is installed; falls back to +parsing ``did.spec`` in a source checkout (same scheme as ``setup.py``). +""" + +from __future__ import annotations + +import re +from pathlib import Path + + +def get_version() -> str: + """ + Return did version (e.g. ``0.23.1``). + + Prefer the installed distribution metadata; otherwise parse ``did.spec``. + """ + try: + from importlib.metadata import PackageNotFoundError, version + except ImportError: # pragma: no cover - Python < 3.8 + from importlib_metadata import ( # type: ignore[import-not-found,no-redef] + PackageNotFoundError, + version, + ) + + try: + return version("did") + except PackageNotFoundError: + pass + + parsed = _version_from_spec() + return parsed if parsed is not None else "0.0.0" + + +def _version_from_spec() -> str | None: + """Parse ``Version`` and numeric ``Release`` from ``did.spec`` if found.""" + here = Path(__file__).resolve().parent + for base in [here.parent, *here.parents]: + spec_path = base / "did.spec" + if not spec_path.is_file(): + continue + text = spec_path.read_text(encoding="utf-8") + vm = re.search(r"^Version:\s*(.+)$", text, re.MULTILINE) + rm = re.search(r"^Release:\s*(\d+)", text, re.MULTILINE) + if not vm or not rm: + return None + ver = vm.group(1).strip() + rel = rm.group(1).strip() + return f"{ver}.{rel}" + return None diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index fd529d8c..d84f1cc4 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -66,6 +66,25 @@ def test_invalid_date() -> None: did.cli.main(argument) +def test_version_flag(capsys: pytest.CaptureFixture[str]) -> None: + """ --version exits before config and prints a version line """ + with pytest.raises(SystemExit) as exc: + did.cli.main(["--version"]) + assert exc.value.code == 0 + out = capsys.readouterr().out.strip() + assert out.startswith("did ") + assert len(out) > len("did ") + + +def test_version_short_flag(capsys: pytest.CaptureFixture[str]) -> None: + """ -V is an alias for --version """ + with pytest.raises(SystemExit) as exc: + did.cli.main(["-V"]) + assert exc.value.code == 0 + out = capsys.readouterr().out.strip() + assert out.startswith("did ") + + def test_conflicting_options() -> None: """ Complain about conflicting options """ did.base.Config(config=MINIMAL) diff --git a/tests/unit/test_version.py b/tests/unit/test_version.py new file mode 100644 index 00000000..2de49de3 --- /dev/null +++ b/tests/unit/test_version.py @@ -0,0 +1,12 @@ +# coding: utf-8 +""" Tests for did.version """ + +import re + +from did.version import get_version + + +def test_get_version_format() -> None: + """ Version string looks like N.N.N (matches did.spec / install metadata). """ + ver = get_version() + assert re.match(r"^\d+\.\d+\.\d+$", ver), ver