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