From 0e4843612b674ae55429d9fbb1722f728b1fec34 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 15 Jun 2026 11:45:44 -0400 Subject: [PATCH] feat(python): add --parent drill-down to galaxy-browse for 5/5 CLI parity Add --parent-gobject-id (integer) to the galaxy-browse CLI command so the Python client matches the Go (-parent) and Rust (--parent-gobject-id) CLIs. When set, drives BrowseChildren paging via browse_children_raw (page size 500, repeated-token guard) and renders the same JSON node shape (flattened object fields + hasChildrenHint + empty children array) and indented-text tree as the root-walk path. --depth is ignored on the parent path with a one-line stderr warning, matching the Go/Rust behaviour. Tests added in TDD order. --- .../src/zb_mom_ww_mxgateway_cli/commands.py | 103 +++++++++++++ clients/python/tests/test_cli.py | 136 ++++++++++++++++++ 2 files changed, 239 insertions(+) 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 9f45e1c..6f11c7a 100644 --- a/clients/python/src/zb_mom_ww_mxgateway_cli/commands.py +++ b/clients/python/src/zb_mom_ww_mxgateway_cli/commands.py @@ -555,6 +555,18 @@ def galaxy_discover(**kwargs: Any) -> None: @main.command("galaxy-browse") @gateway_options +@click.option( + "--parent-gobject-id", + "parent_gobject_id", + default=None, + type=int, + help=( + "Fetch one level of this parent's direct children via BrowseChildren " + "instead of the lazy root walk. Pass a gobject id >= 1. " + "(gobject-id 0 is the server root sentinel — omit the flag to list root objects.) " + "--depth is ignored when this option is set." + ), +) @click.option( "--depth", default=0, @@ -1088,6 +1100,7 @@ async def _galaxy_browse(**kwargs: Any) -> dict[str, Any]: depth = int(kwargs["depth"]) if depth < 0 or depth > 50: raise click.BadParameter("--depth must be between 0 and 50", param_hint="--depth") + parent_gobject_id: int | None = kwargs.get("parent_gobject_id") options = BrowseChildrenOptions( category_ids=tuple(kwargs.get("category_ids") or ()), template_chain_contains=tuple(kwargs.get("template_chain_contains") or ()), @@ -1097,6 +1110,22 @@ async def _galaxy_browse(**kwargs: Any) -> dict[str, Any]: historized_only=bool(kwargs.get("historized_only")), ) async with await _connect_galaxy(kwargs) as galaxy: + if parent_gobject_id is not None: + # Single-level parent drill-down: drive BrowseChildren paging by hand + # and return the children as a flat list. --depth is not meaningful + # here; warn if the caller set it so they know it was ignored. + if depth > 0: + click.echo( + "warning: --depth is ignored when --parent-gobject-id is specified", + err=True, + ) + children = await _browse_children_one_level(galaxy, parent_gobject_id, options) + return { + "command": "galaxy-browse", + "nodes": [_browse_child_dict(obj, hint) for obj, hint in children], + "_text": _render_browse_children(children), + } + roots = await galaxy.browse(options) for root in roots: await _expand_to_depth(root, depth) @@ -1157,6 +1186,80 @@ def _append_browse_node_lines(node: Any, indent: int, lines: list[str]) -> None: _append_browse_node_lines(child, indent + 2, lines) +_BROWSE_CHILDREN_PAGE_SIZE = 500 + + +async def _browse_children_one_level( + galaxy: Any, + parent_gobject_id: int, + options: BrowseChildrenOptions, +) -> list[tuple[Any, bool]]: + """Page through BrowseChildren for ``parent_gobject_id`` and return (object, hint) pairs. + + Uses page size 500 (matching the library constant) and guards against a + repeated page token to prevent an infinite loop if the server misbehaves. + """ + + results: list[tuple[Any, bool]] = [] + seen_page_tokens: set[str] = set() + page_token = "" + + while True: + request = galaxy_pb.BrowseChildrenRequest( + parent_gobject_id=parent_gobject_id, + page_size=_BROWSE_CHILDREN_PAGE_SIZE, + page_token=page_token, + alarm_bearing_only=options.alarm_bearing_only, + historized_only=options.historized_only, + ) + if options.category_ids: + request.category_ids.extend(options.category_ids) + if options.template_chain_contains: + request.template_chain_contains.extend(options.template_chain_contains) + if options.tag_name_glob: + request.tag_name_glob = options.tag_name_glob + if options.include_attributes is not None: + request.include_attributes = options.include_attributes + + reply = await galaxy.browse_children_raw(request) + + for index, obj in enumerate(reply.children): + hint = index < len(reply.child_has_children) and bool(reply.child_has_children[index]) + results.append((obj, hint)) + + page_token = reply.next_page_token + if not page_token: + return results + if page_token in seen_page_tokens: + raise MxGatewayError( + f"galaxy browse children returned repeated page token {page_token!r}" + ) + seen_page_tokens.add(page_token) + + +def _browse_child_dict(obj: Any, has_children_hint: bool) -> dict[str, Any]: + """Render one raw browse child as a node dict matching the lazy-browse shape. + + The ``children`` array is always empty — the parent drill-down path returns + a flat single-level listing without recursive expansion. + """ + + payload = _message_dict(obj) + payload["hasChildrenHint"] = has_children_hint + payload["children"] = [] + return payload + + +def _render_browse_children(children: list[tuple[Any, bool]]) -> str: + """Render a flat one-level child list as a count line plus marker lines.""" + + lines: list[str] = [str(len(children))] + for obj, has_children_hint in children: + marker = "+" if has_children_hint else "-" + lines.append(f"{marker} {obj.tag_name} {obj.browse_name} (gobject {obj.gobject_id})") + return "\n".join(lines) + + 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 diff --git a/clients/python/tests/test_cli.py b/clients/python/tests/test_cli.py index 09011d4..c0f34e6 100644 --- a/clients/python/tests/test_cli.py +++ b/clients/python/tests/test_cli.py @@ -224,12 +224,16 @@ class _FakeGalaxyClient: last_deploy=None, events=None, browse_roots=None, + browse_children_pages=None, ) -> None: self._ok = ok self._objects = objects or [] self._last_deploy = last_deploy self._events = events or [] self._browse_roots = browse_roots or [] + # List of BrowseChildrenReply-like objects to serve in order (paged). + self._browse_children_pages = browse_children_pages or [] + self._browse_children_calls: list = [] self.browse_options = None async def __aenter__(self) -> "_FakeGalaxyClient": @@ -248,6 +252,13 @@ class _FakeGalaxyClient: self.browse_options = options return self._browse_roots + async def browse_children_raw(self, request): + """Return the next queued BrowseChildrenReply page; raises if queue empty.""" + self._browse_children_calls.append(request) + if not self._browse_children_pages: + raise AssertionError("browse_children_raw called but no pages queued") + return self._browse_children_pages.pop(0) + async def get_last_deploy_time(self): # Mirrors galaxy.py: protobuf ToDatetime() yields a timezone-NAIVE UTC datetime. return self._last_deploy @@ -509,3 +520,128 @@ def test_galaxy_browse_rejects_out_of_range_depth( assert result.exit_code != 0 assert "--depth must be between 0 and 50" in result.output + + +# --------------------------------------------------------------------------- +# --parent-gobject-id drill-down tests +# --------------------------------------------------------------------------- + + +def _fake_browse_children_reply(children_and_hints, *, next_page_token=""): + """Build a minimal fake BrowseChildrenReply-like object.""" + from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb + + reply = galaxy_pb.BrowseChildrenReply() + for obj, hint in children_and_hints: + reply.children.append(obj) + reply.child_has_children.append(hint) + reply.next_page_token = next_page_token + return reply + + +def test_galaxy_browse_parent_fetches_one_level_json(monkeypatch: pytest.MonkeyPatch) -> None: + """--parent-gobject-id N calls browse_children_raw and renders one-level JSON.""" + from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb + + child_a = galaxy_pb.GalaxyObject(gobject_id=10, tag_name="PumpA", browse_name="PumpA") + child_b = galaxy_pb.GalaxyObject(gobject_id=11, tag_name="PumpB", browse_name="PumpB") + page = _fake_browse_children_reply([(child_a, True), (child_b, False)]) + fake = _FakeGalaxyClient(browse_children_pages=[page]) + _patch_galaxy_connect(monkeypatch, fake) + + result = CliRunner().invoke( + main, + ["galaxy-browse", "--plaintext", "--parent-gobject-id", "7", "--json"], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + + # One BrowseChildren RPC was issued with the correct parent id. + assert len(fake._browse_children_calls) == 1 + call_req = fake._browse_children_calls[0] + assert call_req.parent_gobject_id == 7 + + # JSON shape mirrors the lazy-browse node shape. + assert payload["command"] == "galaxy-browse" + nodes = payload["nodes"] + assert len(nodes) == 2 + assert nodes[0]["tagName"] == "PumpA" + assert nodes[0]["hasChildrenHint"] is True + assert nodes[0]["children"] == [] + assert nodes[1]["gobjectId"] == 11 + assert nodes[1]["hasChildrenHint"] is False + assert nodes[1]["children"] == [] + + +def test_galaxy_browse_parent_renders_text_tree(monkeypatch: pytest.MonkeyPatch) -> None: + """--parent-gobject-id N text output: count line then marker lines (no indent).""" + from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb + + child = galaxy_pb.GalaxyObject(gobject_id=10, tag_name="PumpA", browse_name="PumpA") + page = _fake_browse_children_reply([(child, False)]) + fake = _FakeGalaxyClient(browse_children_pages=[page]) + _patch_galaxy_connect(monkeypatch, fake) + + result = CliRunner().invoke( + main, + ["galaxy-browse", "--plaintext", "--parent-gobject-id", "7"], + ) + + assert result.exit_code == 0, result.output + lines = result.output.splitlines() + assert lines[0] == "1" + assert lines[1] == "- PumpA PumpA (gobject 10)" + + +def test_galaxy_browse_parent_pages_correctly(monkeypatch: pytest.MonkeyPatch) -> None: + """--parent-gobject-id loops on next_page_token until exhausted.""" + from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb + + child_a = galaxy_pb.GalaxyObject(gobject_id=10, tag_name="PumpA", browse_name="PumpA") + child_b = galaxy_pb.GalaxyObject(gobject_id=11, tag_name="PumpB", browse_name="PumpB") + page1 = _fake_browse_children_reply([(child_a, False)], next_page_token="tok1") + page2 = _fake_browse_children_reply([(child_b, True)]) + fake = _FakeGalaxyClient(browse_children_pages=[page1, page2]) + _patch_galaxy_connect(monkeypatch, fake) + + result = CliRunner().invoke( + main, + ["galaxy-browse", "--plaintext", "--parent-gobject-id", "7", "--json"], + ) + + assert result.exit_code == 0, result.output + assert len(fake._browse_children_calls) == 2 + # Second call must carry the page token from the first reply. + assert fake._browse_children_calls[1].page_token == "tok1" + payload = json.loads(result.output) + assert len(payload["nodes"]) == 2 + + +def test_galaxy_browse_parent_warns_when_depth_also_set( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When both --parent-gobject-id and --depth>0 are supplied a warning is emitted.""" + from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb + + child = galaxy_pb.GalaxyObject(gobject_id=10, tag_name="PumpA", browse_name="PumpA") + page = _fake_browse_children_reply([(child, False)]) + fake = _FakeGalaxyClient(browse_children_pages=[page]) + _patch_galaxy_connect(monkeypatch, fake) + + # CliRunner mixes stderr into output in this Click version. + result = CliRunner().invoke( + main, + ["galaxy-browse", "--plaintext", "--parent-gobject-id", "7", "--depth", "2", "--json"], + ) + + assert result.exit_code == 0, result.output + assert "--depth is ignored" in result.output + + +def test_galaxy_browse_help_shows_parent_gobject_id() -> None: + """--parent-gobject-id appears in the galaxy-browse --help output.""" + result = CliRunner().invoke(main, ["galaxy-browse", "--help"]) + + assert result.exit_code == 0 + assert "--parent-gobject-id" in result.output