feat(python): add galaxy-browse CLI subcommand (§4.6)
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user