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.
This commit is contained in:
Joseph Doherty
2026-06-15 11:45:44 -04:00
parent a56ce0ddbd
commit 0e4843612b
2 changed files with 239 additions and 0 deletions
@@ -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
+136
View File
@@ -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