feat(python): add galaxy-browse CLI subcommand (§4.6)

This commit is contained in:
Joseph Doherty
2026-06-15 10:00:52 -04:00
parent 8cb416ba30
commit 39ec2a3275
3 changed files with 305 additions and 4 deletions
@@ -24,7 +24,7 @@ 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.options import BrowseChildrenOptions, ClientOptions
from zb_mom_ww_mxgateway.values import MxValueInput, to_mx_value
logger = logging.getLogger(__name__)
@@ -553,6 +553,63 @@ def galaxy_discover(**kwargs: Any) -> None:
)
@main.command("galaxy-browse")
@gateway_options
@click.option(
"--depth",
default=0,
type=int,
show_default=True,
help="Eagerly expand the root nodes this many further levels before printing.",
)
@click.option(
"--category-id",
"category_ids",
multiple=True,
type=int,
help="Restrict to objects whose category_id matches one of these ids (repeatable).",
)
@click.option(
"--template-chain-contains",
"template_chain_contains",
multiple=True,
help="Restrict to objects whose template chain contains this entry (repeatable).",
)
@click.option(
"--tag-name-glob",
"tag_name_glob",
default=None,
help="Restrict to objects whose tag name matches this glob.",
)
@click.option(
"--include-attributes",
"include_attributes",
is_flag=True,
help="Include each object's attribute metadata in the browse results.",
)
@click.option(
"--alarm-bearing-only",
"alarm_bearing_only",
is_flag=True,
help="Only return objects that own at least one alarm-bearing attribute.",
)
@click.option(
"--historized-only",
"historized_only",
is_flag=True,
help="Only return objects that own at least one historized attribute.",
)
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def galaxy_browse(**kwargs: Any) -> None:
"""Browse the deployed Galaxy object hierarchy as a lazy-expanded tree."""
_run(
_galaxy_browse(**kwargs),
output_json=kwargs["output_json"],
secrets=_secrets(kwargs),
)
@main.command("galaxy-watch")
@gateway_options
@click.option(
@@ -1027,6 +1084,79 @@ async def _galaxy_discover(**kwargs: Any) -> dict[str, Any]:
}
async def _galaxy_browse(**kwargs: Any) -> dict[str, Any]:
depth = int(kwargs["depth"])
if depth < 0:
raise click.BadParameter("must be non-negative", param_hint="--depth")
options = BrowseChildrenOptions(
category_ids=tuple(kwargs.get("category_ids") or ()),
template_chain_contains=tuple(kwargs.get("template_chain_contains") or ()),
tag_name_glob=kwargs.get("tag_name_glob"),
include_attributes=True if kwargs.get("include_attributes") else None,
alarm_bearing_only=bool(kwargs.get("alarm_bearing_only")),
historized_only=bool(kwargs.get("historized_only")),
)
async with await _connect_galaxy(kwargs) as galaxy:
roots = await galaxy.browse(options)
for root in roots:
await _expand_to_depth(root, depth)
return {
"command": "galaxy-browse",
"nodes": [_browse_node_dict(node) for node in roots],
"_text": _render_browse_tree(roots),
}
async def _expand_to_depth(node: Any, depth: int) -> None:
"""Recursively expand a LazyBrowseNode up to ``depth`` further levels.
``depth == 0`` leaves the node unexpanded so only the requested level is
printed; each level beyond fetches and recurses into the loaded children.
"""
if depth <= 0:
return
if node.has_children_hint:
await node.expand()
for child in node.children:
await _expand_to_depth(child, depth - 1)
def _browse_node_dict(node: Any) -> dict[str, Any]:
"""Render one LazyBrowseNode (and any already-expanded descendants).
Mirrors the ``galaxy-discover`` object shape with an added
``hasChildrenHint`` flag and a nested ``children`` array, matching the
cross-client browse JSON surface.
"""
payload = _message_dict(node.object)
payload["hasChildrenHint"] = bool(node.has_children_hint)
payload["children"] = (
[_browse_node_dict(child) for child in node.children] if node.is_expanded else []
)
return payload
def _render_browse_tree(roots: list[Any]) -> str:
"""Render the lazy-browse roots as a node count plus an indented tree."""
lines: list[str] = [str(len(roots))]
for root in roots:
_append_browse_node_lines(root, 0, lines)
return "\n".join(lines)
def _append_browse_node_lines(node: Any, indent: int, lines: list[str]) -> None:
obj = node.object
marker = "+" if node.has_children_hint else "-"
pad = " " * indent
lines.append(f"{pad}{marker} {obj.tag_name} {obj.browse_name} (gobject {obj.gobject_id})")
if node.is_expanded:
for child in node.children:
_append_browse_node_lines(child, indent + 2, 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
@@ -1131,11 +1261,17 @@ def _emit(
output_json: bool,
text: str | None = None,
) -> None:
# A payload may carry a pre-rendered text representation under the private
# "_text" key (used by commands like galaxy-browse whose text output is a
# custom indented tree rather than the default JSON dump). Strip it so it
# never leaks into the JSON branch.
rendered_text = payload.pop("_text", None) if isinstance(payload, dict) else None
if output_json:
click.echo(json.dumps(payload, sort_keys=True))
return
click.echo(text or json.dumps(payload, sort_keys=True))
click.echo(text or rendered_text or json.dumps(payload, sort_keys=True))
async def _collect_events(