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
|
cleanly. The streaming RPC requires the same `metadata:read` scope as
|
||||||
the other Galaxy methods.
|
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:
|
mirror the other clients:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mxgw-py galaxy-test-connection --plaintext --json
|
mxgw-py galaxy-test-connection --plaintext --json
|
||||||
mxgw-py galaxy-last-deploy --plaintext --json
|
mxgw-py galaxy-last-deploy --plaintext --json
|
||||||
mxgw-py galaxy-discover --plaintext --json
|
mxgw-py galaxy-discover --plaintext --json
|
||||||
|
mxgw-py galaxy-browse --plaintext --json
|
||||||
mxgw-py galaxy-watch --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
|
ISO-8601 timestamp) to suppress the bootstrap event when it matches the
|
||||||
current cached deploy time.
|
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
|
## Authentication And TLS
|
||||||
|
|
||||||
`ClientOptions.api_key` adds this metadata to unary calls and streams:
|
`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.galaxy import GalaxyRepositoryClient
|
||||||
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb
|
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.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
|
from zb_mom_ww_mxgateway.values import MxValueInput, to_mx_value
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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")
|
@main.command("galaxy-watch")
|
||||||
@gateway_options
|
@gateway_options
|
||||||
@click.option(
|
@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]:
|
async def _galaxy_watch(**kwargs: Any) -> dict[str, Any]:
|
||||||
last_seen = kwargs.get("last_seen_deploy_time")
|
last_seen = kwargs.get("last_seen_deploy_time")
|
||||||
last_seen_dt = _parse_datetime(last_seen) if last_seen else None
|
last_seen_dt = _parse_datetime(last_seen) if last_seen else None
|
||||||
@@ -1131,11 +1261,17 @@ def _emit(
|
|||||||
output_json: bool,
|
output_json: bool,
|
||||||
text: str | None = None,
|
text: str | None = 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:
|
if output_json:
|
||||||
click.echo(json.dumps(payload, sort_keys=True))
|
click.echo(json.dumps(payload, sort_keys=True))
|
||||||
return
|
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(
|
async def _collect_events(
|
||||||
|
|||||||
@@ -216,11 +216,21 @@ def test_batch_continues_after_error_line() -> None:
|
|||||||
class _FakeGalaxyClient:
|
class _FakeGalaxyClient:
|
||||||
"""Minimal async-context-manager fake satisfying the galaxy command bodies."""
|
"""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._ok = ok
|
||||||
self._objects = objects or []
|
self._objects = objects or []
|
||||||
self._last_deploy = last_deploy
|
self._last_deploy = last_deploy
|
||||||
self._events = events or []
|
self._events = events or []
|
||||||
|
self._browse_roots = browse_roots or []
|
||||||
|
self.browse_options = None
|
||||||
|
|
||||||
async def __aenter__(self) -> "_FakeGalaxyClient":
|
async def __aenter__(self) -> "_FakeGalaxyClient":
|
||||||
return self
|
return self
|
||||||
@@ -234,6 +244,10 @@ class _FakeGalaxyClient:
|
|||||||
async def discover_hierarchy(self):
|
async def discover_hierarchy(self):
|
||||||
return self._objects
|
return self._objects
|
||||||
|
|
||||||
|
async def browse(self, options=None):
|
||||||
|
self.browse_options = options
|
||||||
|
return self._browse_roots
|
||||||
|
|
||||||
async def get_last_deploy_time(self):
|
async def get_last_deploy_time(self):
|
||||||
# Mirrors galaxy.py: protobuf ToDatetime() yields a timezone-NAIVE UTC datetime.
|
# Mirrors galaxy.py: protobuf ToDatetime() yields a timezone-NAIVE UTC datetime.
|
||||||
return self._last_deploy
|
return self._last_deploy
|
||||||
@@ -326,6 +340,145 @@ def test_galaxy_watch_serializes_deploy_events(monkeypatch: pytest.MonkeyPatch)
|
|||||||
assert len(payload["events"]) == 1
|
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:
|
def test_galaxy_commands_are_registered() -> None:
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
for command in (
|
for command in (
|
||||||
@@ -333,6 +486,7 @@ def test_galaxy_commands_are_registered() -> None:
|
|||||||
"galaxy-last-deploy",
|
"galaxy-last-deploy",
|
||||||
"galaxy-discover",
|
"galaxy-discover",
|
||||||
"galaxy-watch",
|
"galaxy-watch",
|
||||||
|
"galaxy-browse",
|
||||||
):
|
):
|
||||||
result = runner.invoke(main, [command, "--help"])
|
result = runner.invoke(main, [command, "--help"])
|
||||||
assert result.exit_code == 0, result.output
|
assert result.exit_code == 0, result.output
|
||||||
|
|||||||
Reference in New Issue
Block a user