diff --git a/.kiro/specs/build-time-versioning/tasks.md b/.kiro/specs/build-time-versioning/tasks.md index dc71543d..7a21b06d 100644 --- a/.kiro/specs/build-time-versioning/tasks.md +++ b/.kiro/specs/build-time-versioning/tasks.md @@ -90,7 +90,7 @@ - _Boundary: xcecli/cmd/main.go, xcecli/router/router.go_ - _Depends: 4.1_ -- [ ] 5. Core: xceadmin version display +- [x] 5. Core: xceadmin version display - [x] 5.1 (P) meson-build.sh generates a build-info module before packaging - `commit_id`/`build_time` passed as 2 new positional args (`$6`/`$7`, matching this custom_target's existing all-positional style); write block sits between schema-sync and `pip install`, with an independent-per-field `unknown` fallback and a file-existence guard checking the exact path it just wrote (the mirrored tree, not the real source tree) - Observable confirmed live (full real build+test+wheel cycle, independently re-run by review): mirrored tree's `_build_info.py` and the packaged wheel both contain real, matching, non-`unknown` values; 258 tests pass, 3 skips confirmed as documented live-HA e2e tests @@ -98,12 +98,13 @@ - _Boundary: xceadmin/meson.build, xceadmin/meson-build.sh_ - _Depends: 1_ -- [ ] 5.2 (P) Committed fallback stub and CLI version wiring - - Commit a fallback `src/xceadmin/src/xceadmin/_build_info.py` with both fields set to `unknown` (used whenever 5.1's generated version isn't present, e.g. `pip install -e .`) - - Wire `click.version_option()` on `cli.py`'s top-level group, importing `_build_info` defensively and composing its version string from `pyproject.toml`'s static version plus the two build-info fields - - Observable: both `pip install -e .` (no meson) and the meson-built wheel produce a working `xceadmin --version` that prints and exits; the editable-install case shows `unknown` for both build fields, never an import error +- [x] 5.2 (P) Committed fallback stub and CLI version wiring + - Committed `src/xceadmin/src/xceadmin/_build_info.py` with `GIT_COMMIT`/`BUILD_TIME` both `"unknown"` -- exact variable names verified identical to 5.1's `meson-build.sh`-generated module; `cli.py`'s import is a genuine `try/except ImportError` (inline fallback class), not a bare import + - `click.version_option()` wired directly on the top-level `@click.group() def cli()`; version string composed via `importlib.metadata.version("xceadmin")` plus both `_build_info` fields, no hand-parsed `pyproject.toml` + - Observable confirmed live (independently re-verified by review, both install paths): fresh scratch-venv editable install (`pip install --no-deps -e .`, no meson) -> `xceadmin --version` prints `0.1.0 (unknown, unknown)`, exit 0, no import error; the meson-built wheel installed into a separate scratch venv -> `--version` shows real, non-`unknown` commit id + build time. Full meson build/test: 261 passed, 3 skipped (258 baseline + 3 new tests) - _Requirements: 1.1, 1.2, 2.1_ - _Boundary: xceadmin/src/xceadmin_ + - _Depends: 5.1_ - [ ] 6. Integration: cross-toolchain consistency and regression verification - [ ] 6.1 Motivating-incident regression test diff --git a/src/xceadmin/src/xceadmin/_build_info.py b/src/xceadmin/src/xceadmin/_build_info.py new file mode 100644 index 00000000..82f3e305 --- /dev/null +++ b/src/xceadmin/src/xceadmin/_build_info.py @@ -0,0 +1,19 @@ +"""Committed fallback build-info stub (build-time-versioning task 5.2). + +Both fields are always "unknown" here. This file ships checked into git so +that a non-meson `pip install -e .` (the documented fast-iteration path, +`build-deployment.md`) still imports `xceadmin._build_info` cleanly without +ever having run the meson build -- Requirement 1.4's "record as unknown +rather than fail" applies here too. + +When built via the meson custom_target (`src/xceadmin/meson-build.sh`, task +5.1), this exact filename is regenerated with real `GIT_COMMIT`/`BUILD_TIME` +values -- but only in meson's own out-of-tree MIRRORED copy of this package +(`$BUILD_DIR/repo-mirror/src/xceadmin/src/xceadmin/_build_info.py`). This +checked-in copy, under the live source tree, is never overwritten. +""" + +from __future__ import annotations + +GIT_COMMIT = "unknown" +BUILD_TIME = "unknown" diff --git a/src/xceadmin/src/xceadmin/cli.py b/src/xceadmin/src/xceadmin/cli.py index 260e8804..3451f3eb 100644 --- a/src/xceadmin/src/xceadmin/cli.py +++ b/src/xceadmin/src/xceadmin/cli.py @@ -29,6 +29,8 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, timezone +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _package_version import click @@ -37,6 +39,41 @@ from xceadmin.build import build_cluster_config, dump_cluster_config, write_outp from xceadmin.push import probe_config, push_config from xceadmin.scheduler import RecordResult, dump, record_start, record_status, record_stop +try: + from xceadmin import _build_info +except ImportError: # pragma: no cover - defensive only: a committed + # "unknown"-only fallback stub (task 5.2, src/xceadmin/src/xceadmin/ + # _build_info.py) always ships in-tree, and the meson build (task 5.1) + # overwrites it with real values -- this module should therefore always + # be importable in practice. Guarded anyway per the task's own "import + # defensively" wording, matching this feature's established empty -> + # "unknown" fail-soft discipline (go_build, meson-build.sh, + # xce_version_field()) rather than letting a genuinely broken/partial + # install turn into an import error at CLI startup (Requirement 1.4). + class _build_info: # type: ignore[no-redef] + GIT_COMMIT = "unknown" + BUILD_TIME = "unknown" + + +def _compose_version() -> str: + """Composes the string `xceadmin --version` prints: pyproject.toml's + static semantic version -- read via ``importlib.metadata`` (the standard + way to read an *installed* package's own version, rather than + hand-parsing pyproject.toml) -- plus the two build-info fields, which are + either task 5.1's meson-generated real values or task 5.2's committed + "unknown" fallback, whichever this install currently carries + (Requirements 1.1, 1.2, 2.1). + """ + try: + pkg_version = _package_version("xceadmin") + except PackageNotFoundError: # pragma: no cover - only if xceadmin + # itself isn't installed/registered, which shouldn't happen for an + # entry point already running as `xceadmin`; fails soft to + # "unknown" rather than raising, matching Requirement 1.4. + pkg_version = "unknown" + return f"{pkg_version} ({_build_info.GIT_COMMIT}, {_build_info.BUILD_TIME})" + + _DEFAULT_OUTPUT = "cluster.bin" _DEFAULT_CONTROLLER = "localhost:9991" # Every --controller option below also reads this env var (click precedence: @@ -51,6 +88,7 @@ _STATUS_LABEL_WIDTH = 13 # len("started at:") + 2 -- requirements.md's "Status @click.group() +@click.version_option(version=_compose_version(), prog_name="xceadmin") def cli() -> None: """xceadmin: build and deploy XCE cluster configuration.""" diff --git a/src/xceadmin/tests/test_cli.py b/src/xceadmin/tests/test_cli.py index 19040b6a..0ef72634 100644 --- a/src/xceadmin/tests/test_cli.py +++ b/src/xceadmin/tests/test_cli.py @@ -70,6 +70,57 @@ def test_xcecommon_is_importable_independent_of_xceadmin() -> None: import xcecommon # noqa: F401 - import success is the assertion +# --------------------------------------------------------------------------- +# --version (build-time-versioning task 5.2): click.version_option() wired on +# the top-level group, composing pyproject.toml's static semantic version +# with the two _build_info fields (task 5.1's meson-generated real values, or +# task 5.2's committed "unknown"-only fallback stub for a non-meson `pip +# install -e .`). Observable: `xceadmin --version` prints and exits 0, never +# an import error, regardless of which _build_info variant is installed. +# --------------------------------------------------------------------------- + + +def test_cli_version_flag_exits_zero_and_shows_package_version() -> None: + """`xceadmin --version` must exit 0 and print pyproject.toml's static + semantic version (0.1.0) plus build-info fields -- Requirements 1.1, + 1.2, 2.1.""" + runner = CliRunner() + result = runner.invoke(cli, ["--version"]) + + assert result.exit_code == 0, result.output + assert "0.1.0" in result.output # pyproject.toml's static [project].version + assert "version" in result.output.lower() + + +def test_cli_version_flag_includes_build_info_fields() -> None: + """The printed --version string must include BOTH _build_info fields + (whichever variant -- "unknown" fallback or meson-generated real values + -- is currently installed), not just the bare package version, so + --version and the eventual startup log line (out of this task's scope) + can never disagree (Requirement 2.2).""" + from xceadmin import _build_info + + runner = CliRunner() + result = runner.invoke(cli, ["--version"]) + + assert result.exit_code == 0, result.output + assert _build_info.GIT_COMMIT in result.output + assert _build_info.BUILD_TIME in result.output + + +def test_build_info_module_is_importable_with_string_fields() -> None: + """xceadmin._build_info must always be importable with both fields + present as non-empty strings -- whether it's the committed "unknown"-only + fallback stub (task 5.2, a non-meson `pip install -e .`) or the + meson-generated real-value module (task 5.1) this test run happens to be + installed against. Never an import error (tasks.md 5.2's Observable + bullet, Requirement 1.4).""" + from xceadmin import _build_info + + assert isinstance(_build_info.GIT_COMMIT, str) and _build_info.GIT_COMMIT + assert isinstance(_build_info.BUILD_TIME, str) and _build_info.BUILD_TIME + + # --------------------------------------------------------------------------- # config build # ---------------------------------------------------------------------------