diff --git a/clients/python/README.md b/clients/python/README.md index 99d2a00..e166cd5 100644 --- a/clients/python/README.md +++ b/clients/python/README.md @@ -216,13 +216,14 @@ cancelling the surrounding task closes the underlying gRPC stream cleanly. The streaming RPC requires the same `metadata:read` scope as the other Galaxy methods. -The CLI exposes the Galaxy Repository RPCs through four subcommands that +The CLI exposes the Galaxy Repository RPCs through five subcommands that mirror the other clients: ```bash mxgw-py galaxy-test-connection --plaintext --json mxgw-py galaxy-last-deploy --plaintext --json mxgw-py galaxy-discover --plaintext --json +mxgw-py galaxy-browse --plaintext --json mxgw-py galaxy-watch --plaintext --json ``` @@ -231,6 +232,16 @@ mxgw-py galaxy-watch --plaintext --json ISO-8601 timestamp) to suppress the bootstrap event when it matches the current cached deploy time. +`galaxy-browse` wraps the lazy `LazyBrowseNode` walker. Without `--depth` +it lists only the root objects; `--depth N` eagerly expands `N` further +levels before printing. Text output is a node count followed by an indented +tree (`+`/`-` marks the server's has-children hint); `--json` emits nested +`{..., "hasChildrenHint": bool, "children": [...]}` nodes that match the +`galaxy-discover` object shape. The `BrowseChildrenRequest` filters are +exposed as `--category-id` (repeatable), `--template-chain-contains` +(repeatable), `--tag-name-glob`, `--include-attributes`, +`--alarm-bearing-only`, and `--historized-only`, all AND-combined. + ## Authentication And TLS `ClientOptions.api_key` adds this metadata to unary calls and streams: 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 25318ce..ccc296a 100644 --- a/clients/python/src/zb_mom_ww_mxgateway_cli/commands.py +++ b/clients/python/src/zb_mom_ww_mxgateway_cli/commands.py @@ -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( diff --git a/clients/python/tests/test_cli.py b/clients/python/tests/test_cli.py index 2c7f2ca..e9cf95a 100644 --- a/clients/python/tests/test_cli.py +++ b/clients/python/tests/test_cli.py @@ -216,11 +216,21 @@ def test_batch_continues_after_error_line() -> None: class _FakeGalaxyClient: """Minimal async-context-manager fake satisfying the galaxy command bodies.""" - def __init__(self, *, ok: bool = True, objects=None, last_deploy=None, events=None) -> None: + def __init__( + self, + *, + ok: bool = True, + objects=None, + last_deploy=None, + events=None, + browse_roots=None, + ) -> None: self._ok = ok self._objects = objects or [] self._last_deploy = last_deploy self._events = events or [] + self._browse_roots = browse_roots or [] + self.browse_options = None async def __aenter__(self) -> "_FakeGalaxyClient": return self @@ -234,6 +244,10 @@ class _FakeGalaxyClient: async def discover_hierarchy(self): return self._objects + async def browse(self, options=None): + self.browse_options = options + return self._browse_roots + async def get_last_deploy_time(self): # Mirrors galaxy.py: protobuf ToDatetime() yields a timezone-NAIVE UTC datetime. return self._last_deploy @@ -326,6 +340,145 @@ def test_galaxy_watch_serializes_deploy_events(monkeypatch: pytest.MonkeyPatch) assert len(payload["events"]) == 1 +class _FakeBrowseNode: + """Minimal stand-in for LazyBrowseNode covering the CLI render path.""" + + def __init__(self, obj, *, has_children_hint=False, children=None) -> None: + self._object = obj + self._has_children_hint = has_children_hint + self._children = list(children or []) + self._is_expanded = bool(children) + self.expand_calls = 0 + + @property + def object(self): + return self._object + + @property + def has_children_hint(self) -> bool: + return self._has_children_hint + + @property + def children(self): + return list(self._children) + + @property + def is_expanded(self) -> bool: + return self._is_expanded + + async def expand(self) -> None: + self.expand_calls += 1 + self._is_expanded = True + + +def test_galaxy_browse_serializes_nested_nodes(monkeypatch: pytest.MonkeyPatch) -> None: + from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb + + child = _FakeBrowseNode( + galaxy_pb.GalaxyObject(gobject_id=8, tag_name="Pump001", contained_name="Pump001"), + has_children_hint=False, + ) + root = _FakeBrowseNode( + galaxy_pb.GalaxyObject(gobject_id=7, tag_name="Area001", contained_name="Area001"), + has_children_hint=True, + children=[child], + ) + _patch_galaxy_connect(monkeypatch, _FakeGalaxyClient(browse_roots=[root])) + + result = CliRunner().invoke( + main, + ["galaxy-browse", "--plaintext", "--json"], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["command"] == "galaxy-browse" + assert len(payload["nodes"]) == 1 + node = payload["nodes"][0] + assert node["tagName"] == "Area001" + assert node["hasChildrenHint"] is True + assert len(node["children"]) == 1 + assert node["children"][0]["gobjectId"] == 8 + assert node["children"][0]["children"] == [] + + +def test_galaxy_browse_renders_indented_text_tree(monkeypatch: pytest.MonkeyPatch) -> None: + from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb + + child = _FakeBrowseNode( + galaxy_pb.GalaxyObject(gobject_id=8, tag_name="Pump001", browse_name="Pump001"), + ) + root = _FakeBrowseNode( + galaxy_pb.GalaxyObject(gobject_id=7, tag_name="Area001", browse_name="Area001"), + has_children_hint=True, + children=[child], + ) + _patch_galaxy_connect(monkeypatch, _FakeGalaxyClient(browse_roots=[root])) + + result = CliRunner().invoke( + main, + ["galaxy-browse", "--plaintext"], + ) + + assert result.exit_code == 0, result.output + lines = result.output.splitlines() + assert lines[0] == "1" + assert lines[1] == "+ Area001 Area001 (gobject 7)" + assert lines[2] == " - Pump001 Pump001 (gobject 8)" + + +def test_galaxy_browse_forwards_filter_options(monkeypatch: pytest.MonkeyPatch) -> None: + fake = _FakeGalaxyClient(browse_roots=[]) + _patch_galaxy_connect(monkeypatch, fake) + + result = CliRunner().invoke( + main, + [ + "galaxy-browse", + "--plaintext", + "--category-id", + "10", + "--category-id", + "12", + "--template-chain-contains", + "$Pump", + "--tag-name-glob", + "Area*", + "--include-attributes", + "--alarm-bearing-only", + "--historized-only", + "--json", + ], + ) + + assert result.exit_code == 0, result.output + options = fake.browse_options + assert tuple(options.category_ids) == (10, 12) + assert tuple(options.template_chain_contains) == ("$Pump",) + assert options.tag_name_glob == "Area*" + assert options.include_attributes is True + assert options.alarm_bearing_only is True + assert options.historized_only is True + + +def test_galaxy_browse_expands_to_depth(monkeypatch: pytest.MonkeyPatch) -> None: + from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb + + root = _FakeBrowseNode( + galaxy_pb.GalaxyObject(gobject_id=7, tag_name="Area001"), + has_children_hint=True, + ) + _patch_galaxy_connect(monkeypatch, _FakeGalaxyClient(browse_roots=[root])) + + result = CliRunner().invoke( + main, + ["galaxy-browse", "--plaintext", "--depth", "2", "--json"], + ) + + assert result.exit_code == 0, result.output + assert root.expand_calls == 1 + + def test_galaxy_commands_are_registered() -> None: runner = CliRunner() for command in ( @@ -333,6 +486,7 @@ def test_galaxy_commands_are_registered() -> None: "galaxy-last-deploy", "galaxy-discover", "galaxy-watch", + "galaxy-browse", ): result = runner.invoke(main, [command, "--help"]) assert result.exit_code == 0, result.output