From a59fc998e3bc4deb859313491f3bb2382d9be079 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 15 Jun 2026 09:53:01 -0400 Subject: [PATCH] fix(python): UTC-normalize galaxy-last-deploy output, add deploy-event collector, help text, test --- .../src/zb_mom_ww_mxgateway_cli/commands.py | 52 ++++++++++++++++-- clients/python/tests/test_cli.py | 53 ++++++++++++++++++- 2 files changed, 100 insertions(+), 5 deletions(-) diff --git a/clients/python/src/zb_mom_ww_mxgateway_cli/commands.py b/clients/python/src/zb_mom_ww_mxgateway_cli/commands.py index f6e73dd..25318ce 100644 --- a/clients/python/src/zb_mom_ww_mxgateway_cli/commands.py +++ b/clients/python/src/zb_mom_ww_mxgateway_cli/commands.py @@ -22,6 +22,7 @@ from zb_mom_ww_mxgateway.auth import redact_secret from zb_mom_ww_mxgateway.client import GatewayClient from zb_mom_ww_mxgateway.errors import MxGatewayError from zb_mom_ww_mxgateway.galaxy import GalaxyRepositoryClient +from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb from zb_mom_ww_mxgateway.options import ClientOptions from zb_mom_ww_mxgateway.values import MxValueInput, to_mx_value @@ -561,8 +562,20 @@ def galaxy_discover(**kwargs: Any) -> None: help="ISO-8601 timestamp; when it matches the current cached deploy time the " "bootstrap event is suppressed.", ) -@click.option("--max-events", default=1, type=int, show_default=True) -@click.option("--timeout", default=5.0, type=float, show_default=True) +@click.option( + "--max-events", + default=1, + type=int, + show_default=True, + help="Stop after collecting this many deploy events.", +) +@click.option( + "--timeout", + default=5.0, + type=float, + show_default=True, + help="Seconds to wait for each event before stopping.", +) @click.option("--json", "output_json", is_flag=True, help="Emit JSON output.") def galaxy_watch(**kwargs: Any) -> None: """Stream a bounded number of Galaxy deploy events.""" @@ -998,7 +1011,10 @@ async def _galaxy_last_deploy(**kwargs: Any) -> dict[str, Any]: "present": last_deploy is not None, } if last_deploy is not None: - payload["timeOfLastDeploy"] = last_deploy.isoformat() + # galaxy.py returns a timezone-NAIVE UTC datetime (protobuf ToDatetime()). + # Stamp it as UTC so the emitted ISO-8601 carries an unambiguous offset, + # matching the Go client's "...Z" output. + payload["timeOfLastDeploy"] = last_deploy.replace(tzinfo=timezone.utc).isoformat() return payload @@ -1015,7 +1031,7 @@ async def _galaxy_watch(**kwargs: Any) -> dict[str, Any]: last_seen = kwargs.get("last_seen_deploy_time") last_seen_dt = _parse_datetime(last_seen) if last_seen else None async with await _connect_galaxy(kwargs) as galaxy: - events = await _collect_events( + events = await _collect_deploy_events( galaxy.watch_deploy_events(last_seen_dt), max_events=kwargs["max_events"], timeout=kwargs["timeout"], @@ -1178,6 +1194,34 @@ async def _collect_alarm_messages( return collected +async def _collect_deploy_events( + events: Any, + *, + max_events: int, + timeout: float, +) -> list[galaxy_pb.DeployEvent]: + if max_events > MAX_AGGREGATE_EVENTS: + raise click.BadParameter( + f"must be less than or equal to {MAX_AGGREGATE_EVENTS}", + param_hint="--max-events", + ) + + collected: list[galaxy_pb.DeployEvent] = [] + iterator = events.__aiter__() + try: + while len(collected) < max_events: + collected.append(await asyncio.wait_for(iterator.__anext__(), timeout=timeout)) + except StopAsyncIteration: + pass + except asyncio.TimeoutError: + pass + finally: + close = getattr(iterator, "aclose", None) + if close is not None: + await close() + return collected + + def _parse_value(raw_value: str, value_type: str) -> MxValueInput: normalized = value_type.lower() if normalized == "bool": diff --git a/clients/python/tests/test_cli.py b/clients/python/tests/test_cli.py index 3efa07b..2c7f2ca 100644 --- a/clients/python/tests/test_cli.py +++ b/clients/python/tests/test_cli.py @@ -216,9 +216,11 @@ def test_batch_continues_after_error_line() -> None: class _FakeGalaxyClient: """Minimal async-context-manager fake satisfying the galaxy command bodies.""" - def __init__(self, *, ok: bool = True, objects=None) -> None: + def __init__(self, *, ok: bool = True, objects=None, last_deploy=None, events=None) -> None: self._ok = ok self._objects = objects or [] + self._last_deploy = last_deploy + self._events = events or [] async def __aenter__(self) -> "_FakeGalaxyClient": return self @@ -232,6 +234,19 @@ class _FakeGalaxyClient: async def discover_hierarchy(self): return self._objects + async def get_last_deploy_time(self): + # Mirrors galaxy.py: protobuf ToDatetime() yields a timezone-NAIVE UTC datetime. + return self._last_deploy + + def watch_deploy_events(self, _last_seen_deploy_time=None): + events = self._events + + async def _iter(): + for event in events: + yield event + + return _iter() + def _patch_galaxy_connect(monkeypatch: pytest.MonkeyPatch, fake: _FakeGalaxyClient) -> None: async def fake_connect(options, **_kwargs): @@ -275,6 +290,42 @@ def test_galaxy_discover_serializes_objects(monkeypatch: pytest.MonkeyPatch) -> assert payload["objects"][1]["gobjectId"] == 8 +def test_galaxy_last_deploy_emits_utc_iso(monkeypatch: pytest.MonkeyPatch) -> None: + """The naive-UTC deploy time from the library must be emitted as unambiguous UTC ISO-8601.""" + from datetime import datetime + + naive_utc = datetime(2025, 6, 15, 12, 0, 0) # noqa: DTZ001 -- mirrors protobuf ToDatetime() + _patch_galaxy_connect(monkeypatch, _FakeGalaxyClient(last_deploy=naive_utc)) + + result = CliRunner().invoke( + main, + ["galaxy-last-deploy", "--plaintext", "--json"], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["command"] == "galaxy-last-deploy" + assert payload["present"] is True + assert payload["timeOfLastDeploy"].endswith(("+00:00", "Z")) + + +def test_galaxy_watch_serializes_deploy_events(monkeypatch: pytest.MonkeyPatch) -> None: + from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb + + events = [galaxy_pb.DeployEvent(sequence=1)] + _patch_galaxy_connect(monkeypatch, _FakeGalaxyClient(events=events)) + + result = CliRunner().invoke( + main, + ["galaxy-watch", "--plaintext", "--max-events", "1", "--json"], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["command"] == "galaxy-watch" + assert len(payload["events"]) == 1 + + def test_galaxy_commands_are_registered() -> None: runner = CliRunner() for command in (