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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user