1271 lines
48 KiB
Python
1271 lines
48 KiB
Python
"""Command line interface for the MXAccess Gateway Python client."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import io
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
import time
|
|
from collections.abc import Awaitable, Callable
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
import click
|
|
from google.protobuf.json_format import MessageToDict
|
|
|
|
from zb_mom_ww_mxgateway import __version__
|
|
from zb_mom_ww_mxgateway.auth import redact_secret
|
|
from zb_mom_ww_mxgateway.client import GatewayClient
|
|
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.values import MxValueInput, to_mx_value
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MAX_AGGREGATE_EVENTS = 10_000
|
|
|
|
_BATCH_EOR = "__MXGW_BATCH_EOR__"
|
|
|
|
|
|
@click.group()
|
|
def main() -> None:
|
|
"""MXAccess Gateway Python test CLI."""
|
|
|
|
|
|
@main.command()
|
|
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
|
def version(output_json: bool) -> None:
|
|
"""Print client package version information."""
|
|
|
|
payload = {
|
|
"client": "mxgw-py",
|
|
"package": "mxaccess-gateway-client",
|
|
"version": __version__,
|
|
}
|
|
_emit(payload, output_json=output_json, text=f"mxgw-py {__version__}")
|
|
|
|
|
|
@main.command()
|
|
def batch() -> None:
|
|
"""Read commands from stdin and execute each, writing output + __MXGW_BATCH_EOR__ after each.
|
|
|
|
Each non-empty line of stdin is a complete argument string (no quoting support — the
|
|
harness never passes whitespace-containing arguments). Lines are split on runs of ASCII
|
|
whitespace and dispatched through the normal CLI parser. On EOF or an empty line, exit 0.
|
|
|
|
Errors do NOT terminate the loop. Each command's output (including any error JSON) is
|
|
written to stdout followed by a line containing exactly ``__MXGW_BATCH_EOR__``, then
|
|
stdout is flushed. Error output is formatted as ``{"error": "...", "type": "..."}``.
|
|
|
|
Recursive ``batch`` lines are rejected (Client.Python-024) — re-entering the batch
|
|
dispatcher would silently spawn a nested loop reading from the same exhausted stdin.
|
|
"""
|
|
|
|
for raw_line in sys.stdin:
|
|
line = raw_line.rstrip("\n").rstrip("\r")
|
|
if not line:
|
|
# Empty line signals clean exit (matches the spec and .NET behaviour).
|
|
break
|
|
|
|
args = line.split()
|
|
|
|
# Reject a recursive `batch` line outright: the nested invocation would
|
|
# read from the already-exhausted stdin (or, depending on harness, the
|
|
# same stream the outer batch is consuming line-by-line) and silently
|
|
# exit. Surface it as an explicit error block so callers can audit the
|
|
# mis-routed line.
|
|
if args and args[0] == "batch":
|
|
_batch_write_error(
|
|
"RecursiveBatchError",
|
|
"nested 'batch' invocation is not allowed inside batch mode",
|
|
)
|
|
_batch_flush_eor()
|
|
continue
|
|
|
|
_dispatch_batch_line(args)
|
|
|
|
|
|
def _dispatch_batch_line(args: list[str]) -> None:
|
|
"""Run a single batch line through the Click parser directly (no CliRunner).
|
|
|
|
Captures the subcommand's stdout via :func:`contextlib.redirect_stdout` and
|
|
synthesises the standard ``{"error": ..., "type": ...}`` shape on failure.
|
|
Click exceptions (`ClickException`, `UsageError`) are caught and rendered;
|
|
`SystemExit(0)` from a Click command is treated as a clean exit, while a
|
|
non-zero `SystemExit` is rendered as a CLI error. All other exceptions are
|
|
captured and rendered as `{"error": str(exc), "type": exc.__class__.__name__}`
|
|
so the loop never dies.
|
|
"""
|
|
|
|
buffer = io.StringIO()
|
|
exit_code = 0
|
|
exc: BaseException | None = None
|
|
try:
|
|
with contextlib.redirect_stdout(buffer):
|
|
try:
|
|
# `standalone_mode=False` makes Click raise instead of calling
|
|
# `sys.exit`; we still need to handle SystemExit because some
|
|
# commands explicitly raise it (or `click.UsageError` converts
|
|
# to a SystemExit under some entry-point paths).
|
|
main.main(args=args, standalone_mode=False, prog_name="mxgw-py")
|
|
except click.exceptions.Exit as click_exit:
|
|
exit_code = click_exit.exit_code
|
|
except click.ClickException as click_exc:
|
|
exit_code = click_exc.exit_code
|
|
exc = click_exc
|
|
click.echo(f"Error: {click_exc.format_message()}", err=False)
|
|
except SystemExit as sys_exit:
|
|
code = sys_exit.code
|
|
exit_code = int(code) if isinstance(code, int) else (0 if code is None else 1)
|
|
except Exception as captured: # noqa: BLE001 — never let batch loop die
|
|
exc = captured
|
|
exit_code = 1
|
|
|
|
output = buffer.getvalue()
|
|
if exit_code == 0 and exc is None:
|
|
sys.stdout.write(output)
|
|
else:
|
|
if output.lstrip().startswith("{"):
|
|
# Inner command already emitted JSON (e.g. a structured error) —
|
|
# relay verbatim.
|
|
sys.stdout.write(output)
|
|
if output and not output.endswith("\n"):
|
|
sys.stdout.write("\n")
|
|
elif exc is not None:
|
|
_batch_write_error(type(exc).__name__, str(exc))
|
|
else:
|
|
msg = output.strip()
|
|
if msg.startswith("Error: "):
|
|
msg = msg[len("Error: "):]
|
|
_batch_write_error("CliError", msg)
|
|
|
|
_batch_flush_eor()
|
|
|
|
|
|
def _batch_write_error(exc_type: str, message: str) -> None:
|
|
"""Write a JSON error record to stdout in the standard batch error shape."""
|
|
sys.stdout.write(json.dumps({"error": message, "type": exc_type}) + "\n")
|
|
|
|
|
|
def _batch_flush_eor() -> None:
|
|
"""Write the end-of-record sentinel and flush stdout."""
|
|
sys.stdout.write(_BATCH_EOR + "\n")
|
|
sys.stdout.flush()
|
|
|
|
|
|
def gateway_options(command: Callable[..., Any]) -> Callable[..., Any]:
|
|
"""Apply the shared gateway connection options to a Click command."""
|
|
command = click.option("--endpoint", default="localhost:5000", show_default=True)(command)
|
|
command = click.option("--api-key", default=None, help="Gateway API key.")(command)
|
|
command = click.option(
|
|
"--api-key-env",
|
|
default=None,
|
|
help="Environment variable containing the gateway API key.",
|
|
)(command)
|
|
command = click.option("--plaintext", is_flag=True, help="Use plaintext gRPC.")(command)
|
|
command = click.option("--tls", "use_tls", is_flag=True, help="Use TLS gRPC.")(command)
|
|
command = click.option("--ca-file", default=None, help="Custom root certificate file.")(command)
|
|
command = click.option(
|
|
"--require-certificate-validation",
|
|
"require_certificate_validation",
|
|
is_flag=True,
|
|
help="Verify the TLS certificate against the system trust store "
|
|
"instead of the lenient trust-on-first-use default.",
|
|
)(command)
|
|
command = click.option(
|
|
"--server-name-override",
|
|
default=None,
|
|
help="TLS server name override for test environments.",
|
|
)(command)
|
|
command = click.option("--call-timeout", default=30.0, type=float, show_default=True)(command)
|
|
command = click.option("--stream-timeout", default=None, type=float)(command)
|
|
return command
|
|
|
|
|
|
@main.command("open-session")
|
|
@gateway_options
|
|
@click.option("--client-name", default="", help="Client session name.")
|
|
@click.option("--requested-backend", default="", help="Requested backend name.")
|
|
@click.option("--correlation-id", default="", help="Client correlation id.")
|
|
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
|
def open_session(**kwargs: Any) -> None:
|
|
"""Open a gateway session."""
|
|
|
|
_run(
|
|
_open_session(**kwargs),
|
|
output_json=kwargs["output_json"],
|
|
secrets=_secrets(kwargs),
|
|
)
|
|
|
|
|
|
@main.command("close-session")
|
|
@gateway_options
|
|
@click.option("--session-id", required=True, help="Gateway session id.")
|
|
@click.option("--correlation-id", default="", help="Client correlation id.")
|
|
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
|
def close_session(**kwargs: Any) -> None:
|
|
"""Close a gateway session."""
|
|
|
|
_run(
|
|
_close_session(**kwargs),
|
|
output_json=kwargs["output_json"],
|
|
secrets=_secrets(kwargs),
|
|
)
|
|
|
|
|
|
@main.command()
|
|
@gateway_options
|
|
@click.option("--session-id", required=True, help="Gateway session id.")
|
|
@click.option("--message", default="ping", show_default=True, help="Ping payload.")
|
|
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
|
def ping(**kwargs: Any) -> None:
|
|
"""Send a diagnostic ping command."""
|
|
|
|
_run(_ping(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))
|
|
|
|
|
|
@main.command()
|
|
@gateway_options
|
|
@click.option("--session-id", required=True, help="Gateway session id.")
|
|
@click.option("--client-name", required=True, help="MXAccess client name.")
|
|
@click.option("--correlation-id", default="", help="Client correlation id.")
|
|
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
|
def register(**kwargs: Any) -> None:
|
|
"""Invoke MXAccess Register."""
|
|
|
|
_run(
|
|
_register(**kwargs),
|
|
output_json=kwargs["output_json"],
|
|
secrets=_secrets(kwargs),
|
|
)
|
|
|
|
|
|
@main.command("add-item")
|
|
@gateway_options
|
|
@click.option("--session-id", required=True, help="Gateway session id.")
|
|
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
|
|
@click.option("--item", required=True, help="MXAccess item definition.")
|
|
@click.option("--correlation-id", default="", help="Client correlation id.")
|
|
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
|
def add_item(**kwargs: Any) -> None:
|
|
"""Invoke MXAccess AddItem."""
|
|
|
|
_run(
|
|
_add_item(**kwargs),
|
|
output_json=kwargs["output_json"],
|
|
secrets=_secrets(kwargs),
|
|
)
|
|
|
|
|
|
@main.command()
|
|
@gateway_options
|
|
@click.option("--session-id", required=True, help="Gateway session id.")
|
|
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
|
|
@click.option("--item-handle", required=True, type=int, help="MXAccess item handle.")
|
|
@click.option("--correlation-id", default="", help="Client correlation id.")
|
|
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
|
def advise(**kwargs: Any) -> None:
|
|
"""Invoke MXAccess Advise."""
|
|
|
|
_run(_advise(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))
|
|
|
|
|
|
@main.command("subscribe-bulk")
|
|
@gateway_options
|
|
@click.option("--session-id", required=True, help="Gateway session id.")
|
|
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
|
|
@click.option("--items", required=True, help="Comma-separated MXAccess item definitions.")
|
|
@click.option("--correlation-id", default="", help="Client correlation id.")
|
|
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
|
def subscribe_bulk(**kwargs: Any) -> None:
|
|
"""Invoke MXAccess SubscribeBulk."""
|
|
|
|
_run(
|
|
_subscribe_bulk(**kwargs),
|
|
output_json=kwargs["output_json"],
|
|
secrets=_secrets(kwargs),
|
|
)
|
|
|
|
|
|
@main.command("unsubscribe-bulk")
|
|
@gateway_options
|
|
@click.option("--session-id", required=True, help="Gateway session id.")
|
|
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
|
|
@click.option("--item-handles", required=True, help="Comma-separated MXAccess item handles.")
|
|
@click.option("--correlation-id", default="", help="Client correlation id.")
|
|
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
|
def unsubscribe_bulk(**kwargs: Any) -> None:
|
|
"""Invoke MXAccess UnsubscribeBulk."""
|
|
|
|
_run(
|
|
_unsubscribe_bulk(**kwargs),
|
|
output_json=kwargs["output_json"],
|
|
secrets=_secrets(kwargs),
|
|
)
|
|
|
|
|
|
@main.command("read-bulk")
|
|
@gateway_options
|
|
@click.option("--session-id", required=True, help="Gateway session id.")
|
|
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
|
|
@click.option("--items", required=True, help="Comma-separated MXAccess tag addresses.")
|
|
@click.option("--timeout-ms", default=0, type=int, show_default=True,
|
|
help="Per-tag snapshot timeout in milliseconds. 0 = worker default.")
|
|
@click.option("--correlation-id", default="", help="Client correlation id.")
|
|
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
|
def read_bulk(**kwargs: Any) -> None:
|
|
"""Invoke MXAccess ReadBulk — cached value when advised, snapshot otherwise."""
|
|
|
|
_run(_read_bulk(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))
|
|
|
|
|
|
@main.command("write-bulk")
|
|
@gateway_options
|
|
@click.option("--session-id", required=True, help="Gateway session id.")
|
|
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
|
|
@click.option("--item-handles", required=True, help="Comma-separated MXAccess item handles.")
|
|
@click.option("--type", "value_type", default="string", show_default=True)
|
|
@click.option("--values", required=True, help="Comma-separated values, one per item handle.")
|
|
@click.option("--user-id", default=0, type=int, show_default=True)
|
|
@click.option("--correlation-id", default="", help="Client correlation id.")
|
|
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
|
def write_bulk(**kwargs: Any) -> None:
|
|
"""Invoke MXAccess WriteBulk — sequential Write per entry."""
|
|
|
|
_run(_write_bulk(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))
|
|
|
|
|
|
@main.command("write2-bulk")
|
|
@gateway_options
|
|
@click.option("--session-id", required=True, help="Gateway session id.")
|
|
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
|
|
@click.option("--item-handles", required=True, help="Comma-separated MXAccess item handles.")
|
|
@click.option("--type", "value_type", default="string", show_default=True)
|
|
@click.option("--values", required=True, help="Comma-separated values, one per item handle.")
|
|
@click.option("--timestamp", required=True, help="ISO-8601 timestamp shared across all entries.")
|
|
@click.option("--user-id", default=0, type=int, show_default=True)
|
|
@click.option("--correlation-id", default="", help="Client correlation id.")
|
|
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
|
def write2_bulk(**kwargs: Any) -> None:
|
|
"""Invoke MXAccess Write2Bulk — timestamped sequential Write2 per entry."""
|
|
|
|
_run(_write2_bulk(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))
|
|
|
|
|
|
@main.command("write-secured-bulk")
|
|
@gateway_options
|
|
@click.option("--session-id", required=True, help="Gateway session id.")
|
|
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
|
|
@click.option("--item-handles", required=True, help="Comma-separated MXAccess item handles.")
|
|
@click.option("--type", "value_type", default="string", show_default=True)
|
|
@click.option("--values", required=True, help="Comma-separated values, one per item handle.")
|
|
@click.option("--current-user-id", default=0, type=int, show_default=True)
|
|
@click.option("--verifier-user-id", default=0, type=int, show_default=True)
|
|
@click.option("--correlation-id", default="", help="Client correlation id.")
|
|
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
|
def write_secured_bulk(**kwargs: Any) -> None:
|
|
"""Invoke MXAccess WriteSecuredBulk — credential-sensitive."""
|
|
|
|
_run(_write_secured_bulk(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))
|
|
|
|
|
|
@main.command("write-secured2-bulk")
|
|
@gateway_options
|
|
@click.option("--session-id", required=True, help="Gateway session id.")
|
|
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
|
|
@click.option("--item-handles", required=True, help="Comma-separated MXAccess item handles.")
|
|
@click.option("--type", "value_type", default="string", show_default=True)
|
|
@click.option("--values", required=True, help="Comma-separated values, one per item handle.")
|
|
@click.option("--timestamp", required=True, help="ISO-8601 timestamp shared across all entries.")
|
|
@click.option("--current-user-id", default=0, type=int, show_default=True)
|
|
@click.option("--verifier-user-id", default=0, type=int, show_default=True)
|
|
@click.option("--correlation-id", default="", help="Client correlation id.")
|
|
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
|
def write_secured2_bulk(**kwargs: Any) -> None:
|
|
"""Invoke MXAccess WriteSecured2Bulk — timestamped + credential-sensitive."""
|
|
|
|
_run(_write_secured2_bulk(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))
|
|
|
|
|
|
@main.command("bench-read-bulk")
|
|
@gateway_options
|
|
@click.option("--client-name", default="mxgw-python-bench", show_default=True)
|
|
@click.option("--duration-seconds", default=30, type=int, show_default=True)
|
|
@click.option("--warmup-seconds", default=3, type=int, show_default=True)
|
|
@click.option("--bulk-size", default=6, type=int, show_default=True)
|
|
@click.option("--tag-start", default=1, type=int, show_default=True)
|
|
@click.option("--tag-prefix", default="TestMachine_", show_default=True)
|
|
@click.option("--tag-attribute", default="TestChangingInt", show_default=True)
|
|
@click.option("--timeout-ms", default=1500, type=int, show_default=True)
|
|
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
|
def bench_read_bulk(**kwargs: Any) -> None:
|
|
"""Cross-language ReadBulk stress benchmark.
|
|
|
|
Opens its own session, subscribes to bulk-size tags so the worker value
|
|
cache populates from real OnDataChange events, runs ReadBulk in a tight
|
|
loop for duration-seconds, and emits the shared JSON stats schema the
|
|
scripts/bench-read-bulk.ps1 driver collates across all five clients.
|
|
"""
|
|
|
|
_run(_bench_read_bulk(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))
|
|
|
|
|
|
@main.command("stream-events")
|
|
@gateway_options
|
|
@click.option("--session-id", required=True, help="Gateway session id.")
|
|
@click.option("--after-worker-sequence", default=0, type=int, show_default=True)
|
|
@click.option("--max-events", default=1, type=int, show_default=True)
|
|
@click.option("--timeout", default=5.0, type=float, show_default=True)
|
|
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
|
def stream_events(**kwargs: Any) -> None:
|
|
"""Stream a bounded number of events."""
|
|
|
|
_run(
|
|
_stream_events(**kwargs),
|
|
output_json=kwargs["output_json"],
|
|
secrets=_secrets(kwargs),
|
|
)
|
|
|
|
|
|
@main.command("stream-alarms")
|
|
@gateway_options
|
|
@click.option("--filter-prefix", default="", help="Alarm-reference prefix filter.")
|
|
@click.option("--max-messages", default=1, type=int, show_default=True)
|
|
@click.option("--timeout", default=5.0, type=float, show_default=True)
|
|
@click.option("--correlation-id", default="", help="Client correlation id.")
|
|
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
|
def stream_alarms(**kwargs: Any) -> None:
|
|
"""Stream a bounded number of messages from the gateway's central alarm feed."""
|
|
|
|
_run(
|
|
_stream_alarms(**kwargs),
|
|
output_json=kwargs["output_json"],
|
|
secrets=_secrets(kwargs),
|
|
)
|
|
|
|
|
|
@main.command("acknowledge-alarm")
|
|
@gateway_options
|
|
@click.option("--reference", required=True, help="Alarm full reference to acknowledge.")
|
|
@click.option("--comment", default="", help="Acknowledgement comment.")
|
|
@click.option("--operator", default="", help="Operator user name.")
|
|
@click.option("--correlation-id", default="", help="Client correlation id.")
|
|
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
|
def acknowledge_alarm(**kwargs: Any) -> None:
|
|
"""Acknowledge an active MXAccess alarm condition (session-less)."""
|
|
|
|
_run(
|
|
_acknowledge_alarm(**kwargs),
|
|
output_json=kwargs["output_json"],
|
|
secrets=_secrets(kwargs),
|
|
)
|
|
|
|
|
|
@main.command()
|
|
@gateway_options
|
|
@click.option("--session-id", required=True, help="Gateway session id.")
|
|
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
|
|
@click.option("--item-handle", required=True, type=int, help="MXAccess item handle.")
|
|
@click.option("--type", "value_type", default="string", show_default=True)
|
|
@click.option("--value", required=True, help="Value to write.")
|
|
@click.option("--user-id", default=0, type=int, show_default=True)
|
|
@click.option("--correlation-id", default="", help="Client correlation id.")
|
|
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
|
def write(**kwargs: Any) -> None:
|
|
"""Invoke MXAccess Write."""
|
|
|
|
_run(_write(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))
|
|
|
|
|
|
@main.command()
|
|
@gateway_options
|
|
@click.option("--session-id", required=True, help="Gateway session id.")
|
|
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
|
|
@click.option("--item-handle", required=True, type=int, help="MXAccess item handle.")
|
|
@click.option("--type", "value_type", default="string", show_default=True)
|
|
@click.option("--value", required=True, help="Value to write.")
|
|
@click.option("--timestamp", required=True, help="ISO-8601 timestamp value.")
|
|
@click.option("--user-id", default=0, type=int, show_default=True)
|
|
@click.option("--correlation-id", default="", help="Client correlation id.")
|
|
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
|
def write2(**kwargs: Any) -> None:
|
|
"""Invoke MXAccess Write2."""
|
|
|
|
_run(_write2(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))
|
|
|
|
|
|
@main.command()
|
|
@gateway_options
|
|
@click.option("--client-name", default="mxgw-py-smoke", show_default=True)
|
|
@click.option("--item", required=True, help="MXAccess item definition.")
|
|
@click.option("--max-events", default=1, type=int, show_default=True)
|
|
@click.option("--timeout", default=5.0, type=float, show_default=True)
|
|
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
|
def smoke(**kwargs: Any) -> None:
|
|
"""Run a bounded open/register/add/advise/stream/close smoke flow."""
|
|
|
|
_run(_smoke(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))
|
|
|
|
|
|
@main.command("galaxy-test-connection")
|
|
@gateway_options
|
|
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
|
def galaxy_test_connection(**kwargs: Any) -> None:
|
|
"""Test whether the gateway can reach the Galaxy Repository DB."""
|
|
|
|
_run(
|
|
_galaxy_test_connection(**kwargs),
|
|
output_json=kwargs["output_json"],
|
|
secrets=_secrets(kwargs),
|
|
)
|
|
|
|
|
|
@main.command("galaxy-last-deploy")
|
|
@gateway_options
|
|
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
|
def galaxy_last_deploy(**kwargs: Any) -> None:
|
|
"""Read the last Galaxy deploy timestamp."""
|
|
|
|
_run(
|
|
_galaxy_last_deploy(**kwargs),
|
|
output_json=kwargs["output_json"],
|
|
secrets=_secrets(kwargs),
|
|
)
|
|
|
|
|
|
@main.command("galaxy-discover")
|
|
@gateway_options
|
|
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
|
def galaxy_discover(**kwargs: Any) -> None:
|
|
"""Enumerate the deployed Galaxy object hierarchy."""
|
|
|
|
_run(
|
|
_galaxy_discover(**kwargs),
|
|
output_json=kwargs["output_json"],
|
|
secrets=_secrets(kwargs),
|
|
)
|
|
|
|
|
|
@main.command("galaxy-watch")
|
|
@gateway_options
|
|
@click.option(
|
|
"--last-seen-deploy-time",
|
|
"last_seen_deploy_time",
|
|
default=None,
|
|
help="ISO-8601 timestamp; when it matches the current cached deploy time the "
|
|
"bootstrap event is suppressed.",
|
|
)
|
|
@click.option(
|
|
"--max-events",
|
|
default=1,
|
|
type=int,
|
|
show_default=True,
|
|
help="Stop after collecting this many deploy events.",
|
|
)
|
|
@click.option(
|
|
"--timeout",
|
|
default=5.0,
|
|
type=float,
|
|
show_default=True,
|
|
help="Seconds to wait for each event before stopping.",
|
|
)
|
|
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
|
def galaxy_watch(**kwargs: Any) -> None:
|
|
"""Stream a bounded number of Galaxy deploy events."""
|
|
|
|
_run(
|
|
_galaxy_watch(**kwargs),
|
|
output_json=kwargs["output_json"],
|
|
secrets=_secrets(kwargs),
|
|
)
|
|
|
|
|
|
async def _open_session(**kwargs: Any) -> dict[str, Any]:
|
|
async with await _connect(kwargs) as client:
|
|
reply = await client.open_session_raw(
|
|
pb.OpenSessionRequest(
|
|
requested_backend=kwargs["requested_backend"],
|
|
client_session_name=kwargs["client_name"],
|
|
client_correlation_id=kwargs["correlation_id"],
|
|
),
|
|
)
|
|
return {"sessionId": reply.session_id, "rawReply": _message_dict(reply)}
|
|
|
|
|
|
async def _close_session(**kwargs: Any) -> dict[str, Any]:
|
|
async with await _connect(kwargs) as client:
|
|
reply = await client.close_session_raw(
|
|
pb.CloseSessionRequest(
|
|
session_id=kwargs["session_id"],
|
|
client_correlation_id=kwargs["correlation_id"],
|
|
),
|
|
)
|
|
return {"sessionId": reply.session_id, "rawReply": _message_dict(reply)}
|
|
|
|
|
|
async def _ping(**kwargs: Any) -> dict[str, Any]:
|
|
async with await _connect(kwargs) as client:
|
|
reply = await client.invoke_raw(
|
|
pb.MxCommandRequest(
|
|
session_id=kwargs["session_id"],
|
|
command=pb.MxCommand(
|
|
kind=pb.MX_COMMAND_KIND_PING,
|
|
ping=pb.PingCommand(message=kwargs["message"]),
|
|
),
|
|
),
|
|
)
|
|
return {"kind": "ping", "rawReply": _message_dict(reply)}
|
|
|
|
|
|
async def _register(**kwargs: Any) -> dict[str, Any]:
|
|
async with await _connect(kwargs) as client:
|
|
session = _session(client, kwargs["session_id"])
|
|
server_handle = await session.register(
|
|
kwargs["client_name"],
|
|
correlation_id=kwargs["correlation_id"],
|
|
)
|
|
return {"serverHandle": server_handle}
|
|
|
|
|
|
async def _add_item(**kwargs: Any) -> dict[str, Any]:
|
|
async with await _connect(kwargs) as client:
|
|
session = _session(client, kwargs["session_id"])
|
|
item_handle = await session.add_item(
|
|
kwargs["server_handle"],
|
|
kwargs["item"],
|
|
correlation_id=kwargs["correlation_id"],
|
|
)
|
|
return {"itemHandle": item_handle}
|
|
|
|
|
|
async def _advise(**kwargs: Any) -> dict[str, Any]:
|
|
async with await _connect(kwargs) as client:
|
|
session = _session(client, kwargs["session_id"])
|
|
await session.advise(
|
|
kwargs["server_handle"],
|
|
kwargs["item_handle"],
|
|
correlation_id=kwargs["correlation_id"],
|
|
)
|
|
return {"ok": True}
|
|
|
|
|
|
async def _subscribe_bulk(**kwargs: Any) -> dict[str, Any]:
|
|
async with await _connect(kwargs) as client:
|
|
session = _session(client, kwargs["session_id"])
|
|
results = await session.subscribe_bulk(
|
|
kwargs["server_handle"],
|
|
_parse_string_list(kwargs["items"]),
|
|
correlation_id=kwargs["correlation_id"],
|
|
)
|
|
return {"results": [_message_dict(result) for result in results]}
|
|
|
|
|
|
async def _unsubscribe_bulk(**kwargs: Any) -> dict[str, Any]:
|
|
async with await _connect(kwargs) as client:
|
|
session = _session(client, kwargs["session_id"])
|
|
results = await session.unsubscribe_bulk(
|
|
kwargs["server_handle"],
|
|
_parse_int_list(kwargs["item_handles"]),
|
|
correlation_id=kwargs["correlation_id"],
|
|
)
|
|
return {"results": [_message_dict(result) for result in results]}
|
|
|
|
|
|
async def _read_bulk(**kwargs: Any) -> dict[str, Any]:
|
|
async with await _connect(kwargs) as client:
|
|
session = _session(client, kwargs["session_id"])
|
|
results = await session.read_bulk(
|
|
kwargs["server_handle"],
|
|
_parse_string_list(kwargs["items"]),
|
|
timeout_ms=kwargs["timeout_ms"],
|
|
correlation_id=kwargs["correlation_id"],
|
|
)
|
|
return {"results": [_message_dict(result) for result in results]}
|
|
|
|
|
|
def _build_write_bulk_entries(kwargs: dict[str, Any]):
|
|
"""Build (item_handle, MxValue) pairs for the bulk-write families.
|
|
|
|
The CLI accepts a single ``--type`` plus ``--values`` (comma-separated
|
|
string-encoded values, one per ``--item-handles`` entry). Returns the
|
|
parsed item-handle list and the per-entry MxValue protobuf instances —
|
|
callers wrap these into the appropriate per-entry message type.
|
|
"""
|
|
|
|
handles = _parse_int_list(kwargs["item_handles"])
|
|
value_texts = _parse_string_list(kwargs["values"])
|
|
if len(handles) != len(value_texts):
|
|
raise click.UsageError(
|
|
f"item-handles count ({len(handles)}) does not match values count ({len(value_texts)})",
|
|
)
|
|
parsed = [_parse_value(text, kwargs["value_type"]) for text in value_texts]
|
|
values = [to_mx_value(v) for v in parsed]
|
|
return handles, values
|
|
|
|
|
|
async def _write_bulk(**kwargs: Any) -> dict[str, Any]:
|
|
handles, values = _build_write_bulk_entries(kwargs)
|
|
entries = [
|
|
pb.WriteBulkEntry(item_handle=handle, user_id=kwargs["user_id"], value=value)
|
|
for handle, value in zip(handles, values)
|
|
]
|
|
async with await _connect(kwargs) as client:
|
|
session = _session(client, kwargs["session_id"])
|
|
results = await session.write_bulk(
|
|
kwargs["server_handle"],
|
|
entries,
|
|
correlation_id=kwargs["correlation_id"],
|
|
)
|
|
return {"results": [_message_dict(result) for result in results]}
|
|
|
|
|
|
async def _write2_bulk(**kwargs: Any) -> dict[str, Any]:
|
|
handles, values = _build_write_bulk_entries(kwargs)
|
|
timestamp_value = to_mx_value(_parse_datetime(kwargs["timestamp"]))
|
|
entries = [
|
|
pb.Write2BulkEntry(
|
|
item_handle=handle,
|
|
user_id=kwargs["user_id"],
|
|
value=value,
|
|
timestamp_value=timestamp_value,
|
|
)
|
|
for handle, value in zip(handles, values)
|
|
]
|
|
async with await _connect(kwargs) as client:
|
|
session = _session(client, kwargs["session_id"])
|
|
results = await session.write2_bulk(
|
|
kwargs["server_handle"],
|
|
entries,
|
|
correlation_id=kwargs["correlation_id"],
|
|
)
|
|
return {"results": [_message_dict(result) for result in results]}
|
|
|
|
|
|
async def _write_secured_bulk(**kwargs: Any) -> dict[str, Any]:
|
|
handles, values = _build_write_bulk_entries(kwargs)
|
|
entries = [
|
|
pb.WriteSecuredBulkEntry(
|
|
item_handle=handle,
|
|
current_user_id=kwargs["current_user_id"],
|
|
verifier_user_id=kwargs["verifier_user_id"],
|
|
value=value,
|
|
)
|
|
for handle, value in zip(handles, values)
|
|
]
|
|
async with await _connect(kwargs) as client:
|
|
session = _session(client, kwargs["session_id"])
|
|
results = await session.write_secured_bulk(
|
|
kwargs["server_handle"],
|
|
entries,
|
|
correlation_id=kwargs["correlation_id"],
|
|
)
|
|
return {"results": [_message_dict(result) for result in results]}
|
|
|
|
|
|
async def _write_secured2_bulk(**kwargs: Any) -> dict[str, Any]:
|
|
handles, values = _build_write_bulk_entries(kwargs)
|
|
timestamp_value = to_mx_value(_parse_datetime(kwargs["timestamp"]))
|
|
entries = [
|
|
pb.WriteSecured2BulkEntry(
|
|
item_handle=handle,
|
|
current_user_id=kwargs["current_user_id"],
|
|
verifier_user_id=kwargs["verifier_user_id"],
|
|
value=value,
|
|
timestamp_value=timestamp_value,
|
|
)
|
|
for handle, value in zip(handles, values)
|
|
]
|
|
async with await _connect(kwargs) as client:
|
|
session = _session(client, kwargs["session_id"])
|
|
results = await session.write_secured2_bulk(
|
|
kwargs["server_handle"],
|
|
entries,
|
|
correlation_id=kwargs["correlation_id"],
|
|
)
|
|
return {"results": [_message_dict(result) for result in results]}
|
|
|
|
|
|
async def _bench_read_bulk(**kwargs: Any) -> dict[str, Any]:
|
|
"""ReadBulk stress benchmark — matches the .NET / Go / Rust / Java schema."""
|
|
|
|
bulk_size = int(kwargs["bulk_size"])
|
|
if bulk_size < 1:
|
|
raise click.UsageError("bulk-size must be positive")
|
|
duration_seconds = int(kwargs["duration_seconds"])
|
|
warmup_seconds = int(kwargs["warmup_seconds"])
|
|
tag_start = int(kwargs["tag_start"])
|
|
tag_prefix = kwargs["tag_prefix"]
|
|
tag_attribute = kwargs["tag_attribute"]
|
|
timeout_ms = int(kwargs["timeout_ms"])
|
|
client_name = kwargs["client_name"]
|
|
tags = [f"{tag_prefix}{i:03d}.{tag_attribute}" for i in range(tag_start, tag_start + bulk_size)]
|
|
|
|
async with await _connect(kwargs) as client:
|
|
session = await client.open_session(client_session_name=client_name)
|
|
server_handle = 0
|
|
item_handles: list[int] = []
|
|
try:
|
|
server_handle = await session.register(client_name)
|
|
subscribe_results = await session.subscribe_bulk(server_handle, tags)
|
|
item_handles = [r.item_handle for r in subscribe_results if r.was_successful]
|
|
|
|
# Warm-up window so JIT / connection pool / first-call costs are
|
|
# amortised before the measurement window opens.
|
|
warmup_deadline = time.perf_counter() + warmup_seconds
|
|
while time.perf_counter() < warmup_deadline:
|
|
await session.read_bulk(server_handle, tags, timeout_ms=timeout_ms)
|
|
|
|
latencies_ms: list[float] = []
|
|
total_results = 0
|
|
cached_results = 0
|
|
successful = 0
|
|
failed = 0
|
|
steady_start = time.perf_counter()
|
|
steady_deadline = steady_start + duration_seconds
|
|
while time.perf_counter() < steady_deadline:
|
|
call_start = time.perf_counter()
|
|
try:
|
|
results = await session.read_bulk(server_handle, tags, timeout_ms=timeout_ms)
|
|
except Exception:
|
|
failed += 1
|
|
latencies_ms.append((time.perf_counter() - call_start) * 1000.0)
|
|
continue
|
|
latencies_ms.append((time.perf_counter() - call_start) * 1000.0)
|
|
successful += 1
|
|
for r in results:
|
|
total_results += 1
|
|
if r.was_cached:
|
|
cached_results += 1
|
|
steady_elapsed = time.perf_counter() - steady_start
|
|
total_calls = successful + failed
|
|
calls_per_second = total_calls / steady_elapsed if steady_elapsed > 0 else 0.0
|
|
finally:
|
|
if item_handles:
|
|
try:
|
|
await session.unsubscribe_bulk(server_handle, item_handles)
|
|
except Exception as exc: # noqa: BLE001 — bench is best-effort
|
|
logger.warning("bench-read-bulk: unsubscribe_bulk cleanup failed: %s", exc)
|
|
try:
|
|
await session.close()
|
|
except Exception as exc: # noqa: BLE001 — bench is best-effort
|
|
logger.warning("bench-read-bulk: session.close cleanup failed: %s", exc)
|
|
|
|
return {
|
|
"language": "python",
|
|
"command": "bench-read-bulk",
|
|
"endpoint": kwargs.get("endpoint"),
|
|
"clientName": client_name,
|
|
"bulkSize": bulk_size,
|
|
"durationSeconds": duration_seconds,
|
|
"warmupSeconds": warmup_seconds,
|
|
"durationMs": int(steady_elapsed * 1000),
|
|
"tags": tags,
|
|
"totalCalls": total_calls,
|
|
"successfulCalls": successful,
|
|
"failedCalls": failed,
|
|
"totalReadResults": total_results,
|
|
"cachedReadResults": cached_results,
|
|
"callsPerSecond": round(calls_per_second, 2),
|
|
"latencyMs": _percentile_summary(latencies_ms),
|
|
}
|
|
|
|
|
|
def _percentile_summary(sample: list[float]) -> dict[str, float]:
|
|
if not sample:
|
|
return {"p50": 0.0, "p95": 0.0, "p99": 0.0, "max": 0.0, "mean": 0.0}
|
|
sorted_sample = sorted(sample)
|
|
return {
|
|
"p50": round(_percentile(sorted_sample, 0.50), 3),
|
|
"p95": round(_percentile(sorted_sample, 0.95), 3),
|
|
"p99": round(_percentile(sorted_sample, 0.99), 3),
|
|
"max": round(sorted_sample[-1], 3),
|
|
"mean": round(sum(sample) / len(sample), 3),
|
|
}
|
|
|
|
|
|
def _percentile(sorted_sample: list[float], quantile: float) -> float:
|
|
"""Nearest-rank with linear interpolation; matches every other client."""
|
|
n = len(sorted_sample)
|
|
if n == 0:
|
|
return 0.0
|
|
if n == 1:
|
|
return sorted_sample[0]
|
|
rank = quantile * (n - 1)
|
|
lower = int(rank)
|
|
upper = min(lower + 1, n - 1)
|
|
fraction = rank - lower
|
|
return sorted_sample[lower] + (sorted_sample[upper] - sorted_sample[lower]) * fraction
|
|
|
|
|
|
async def _stream_events(**kwargs: Any) -> dict[str, Any]:
|
|
async with await _connect(kwargs) as client:
|
|
session = _session(client, kwargs["session_id"])
|
|
events = await _collect_events(
|
|
session.stream_events(after_worker_sequence=kwargs["after_worker_sequence"]),
|
|
max_events=kwargs["max_events"],
|
|
timeout=kwargs["timeout"],
|
|
)
|
|
return {"events": [_message_dict(event) for event in events]}
|
|
|
|
|
|
async def _stream_alarms(**kwargs: Any) -> dict[str, Any]:
|
|
async with await _connect(kwargs) as client:
|
|
messages = await _collect_alarm_messages(
|
|
client.stream_alarms(
|
|
pb.StreamAlarmsRequest(
|
|
client_correlation_id=kwargs["correlation_id"],
|
|
alarm_filter_prefix=kwargs["filter_prefix"],
|
|
),
|
|
),
|
|
max_messages=kwargs["max_messages"],
|
|
timeout=kwargs["timeout"],
|
|
)
|
|
return {"messages": [_message_dict(message) for message in messages]}
|
|
|
|
|
|
async def _acknowledge_alarm(**kwargs: Any) -> dict[str, Any]:
|
|
async with await _connect(kwargs) as client:
|
|
reply = await client.acknowledge_alarm(
|
|
pb.AcknowledgeAlarmRequest(
|
|
client_correlation_id=kwargs["correlation_id"],
|
|
alarm_full_reference=kwargs["reference"],
|
|
comment=kwargs["comment"],
|
|
operator_user=kwargs["operator"],
|
|
),
|
|
)
|
|
return _message_dict(reply)
|
|
|
|
|
|
async def _write(**kwargs: Any) -> dict[str, Any]:
|
|
value = _parse_value(kwargs["value"], kwargs["value_type"])
|
|
async with await _connect(kwargs) as client:
|
|
session = _session(client, kwargs["session_id"])
|
|
await session.write(
|
|
kwargs["server_handle"],
|
|
kwargs["item_handle"],
|
|
value,
|
|
user_id=kwargs["user_id"],
|
|
correlation_id=kwargs["correlation_id"],
|
|
)
|
|
return {"ok": True}
|
|
|
|
|
|
async def _write2(**kwargs: Any) -> dict[str, Any]:
|
|
value = _parse_value(kwargs["value"], kwargs["value_type"])
|
|
timestamp = _parse_datetime(kwargs["timestamp"])
|
|
async with await _connect(kwargs) as client:
|
|
session = _session(client, kwargs["session_id"])
|
|
await session.write2(
|
|
kwargs["server_handle"],
|
|
kwargs["item_handle"],
|
|
value,
|
|
timestamp,
|
|
user_id=kwargs["user_id"],
|
|
correlation_id=kwargs["correlation_id"],
|
|
)
|
|
return {"ok": True}
|
|
|
|
|
|
async def _smoke(**kwargs: Any) -> dict[str, Any]:
|
|
async with await _connect(kwargs) as client:
|
|
session = await client.open_session(client_session_name=kwargs["client_name"])
|
|
closed = False
|
|
try:
|
|
server_handle = await session.register(kwargs["client_name"])
|
|
item_handle = await session.add_item(server_handle, kwargs["item"])
|
|
await session.advise(server_handle, item_handle)
|
|
events = await _collect_events(
|
|
session.stream_events(),
|
|
max_events=kwargs["max_events"],
|
|
timeout=kwargs["timeout"],
|
|
)
|
|
return {
|
|
"sessionId": session.session_id,
|
|
"serverHandle": server_handle,
|
|
"itemHandle": item_handle,
|
|
"events": [_message_dict(event) for event in events],
|
|
}
|
|
finally:
|
|
if not closed:
|
|
await session.close()
|
|
|
|
|
|
async def _galaxy_test_connection(**kwargs: Any) -> dict[str, Any]:
|
|
async with await _connect_galaxy(kwargs) as galaxy:
|
|
ok = await galaxy.test_connection()
|
|
return {"command": "galaxy-test-connection", "ok": ok}
|
|
|
|
|
|
async def _galaxy_last_deploy(**kwargs: Any) -> dict[str, Any]:
|
|
async with await _connect_galaxy(kwargs) as galaxy:
|
|
last_deploy = await galaxy.get_last_deploy_time()
|
|
payload: dict[str, Any] = {
|
|
"command": "galaxy-last-deploy",
|
|
"present": last_deploy is not None,
|
|
}
|
|
if last_deploy is not None:
|
|
# galaxy.py returns a timezone-NAIVE UTC datetime (protobuf ToDatetime()).
|
|
# Stamp it as UTC so the emitted ISO-8601 carries an unambiguous offset,
|
|
# matching the Go client's "...Z" output.
|
|
payload["timeOfLastDeploy"] = last_deploy.replace(tzinfo=timezone.utc).isoformat()
|
|
return payload
|
|
|
|
|
|
async def _galaxy_discover(**kwargs: Any) -> dict[str, Any]:
|
|
async with await _connect_galaxy(kwargs) as galaxy:
|
|
objects = await galaxy.discover_hierarchy()
|
|
return {
|
|
"command": "galaxy-discover",
|
|
"objects": [_message_dict(obj) for obj in objects],
|
|
}
|
|
|
|
|
|
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
|
|
async with await _connect_galaxy(kwargs) as galaxy:
|
|
events = await _collect_deploy_events(
|
|
galaxy.watch_deploy_events(last_seen_dt),
|
|
max_events=kwargs["max_events"],
|
|
timeout=kwargs["timeout"],
|
|
)
|
|
return {
|
|
"command": "galaxy-watch",
|
|
"events": [_message_dict(event) for event in events],
|
|
}
|
|
|
|
|
|
async def _connect(kwargs: dict[str, Any]) -> GatewayClient:
|
|
api_key = kwargs.get("api_key") or _api_key_from_env(kwargs.get("api_key_env"))
|
|
return await GatewayClient.connect(
|
|
ClientOptions(
|
|
endpoint=kwargs["endpoint"],
|
|
api_key=api_key,
|
|
plaintext=_use_plaintext(kwargs),
|
|
ca_file=kwargs.get("ca_file"),
|
|
require_certificate_validation=bool(kwargs.get("require_certificate_validation")),
|
|
server_name_override=kwargs.get("server_name_override"),
|
|
call_timeout=kwargs.get("call_timeout"),
|
|
stream_timeout=kwargs.get("stream_timeout"),
|
|
),
|
|
)
|
|
|
|
|
|
async def _connect_galaxy(kwargs: dict[str, Any]) -> GalaxyRepositoryClient:
|
|
api_key = kwargs.get("api_key") or _api_key_from_env(kwargs.get("api_key_env"))
|
|
return await GalaxyRepositoryClient.connect(
|
|
ClientOptions(
|
|
endpoint=kwargs["endpoint"],
|
|
api_key=api_key,
|
|
plaintext=_use_plaintext(kwargs),
|
|
ca_file=kwargs.get("ca_file"),
|
|
require_certificate_validation=bool(kwargs.get("require_certificate_validation")),
|
|
server_name_override=kwargs.get("server_name_override"),
|
|
call_timeout=kwargs.get("call_timeout"),
|
|
stream_timeout=kwargs.get("stream_timeout"),
|
|
),
|
|
)
|
|
|
|
|
|
def _session(client: GatewayClient, session_id: str):
|
|
from zb_mom_ww_mxgateway.session import Session
|
|
|
|
return Session(client=client, session_id=session_id)
|
|
|
|
|
|
def _use_plaintext(kwargs: dict[str, Any]) -> bool:
|
|
"""Resolve the plaintext / TLS contract from the CLI flags.
|
|
|
|
TLS is the default. ``--plaintext`` is the only way to opt in to an
|
|
unencrypted channel; ``--tls`` is accepted as a redundant explicit
|
|
affirmation. Combining the two is a usage error (regression-guarded by
|
|
Client.Python-023 — the previous silent ``localhost:`` /
|
|
``127.0.0.1:`` auto-plaintext branch leaked the API-key bearer over a
|
|
plaintext channel when a user ran the gateway behind TLS on loopback).
|
|
"""
|
|
|
|
plaintext = bool(kwargs.get("plaintext"))
|
|
use_tls = bool(kwargs.get("use_tls"))
|
|
if plaintext and use_tls:
|
|
raise click.UsageError("--plaintext and --tls are mutually exclusive")
|
|
return plaintext
|
|
|
|
|
|
def _api_key_from_env(name: str | None) -> str | None:
|
|
if not name:
|
|
return None
|
|
return os.environ.get(name)
|
|
|
|
|
|
def _secrets(kwargs: dict[str, Any]) -> list[str | None]:
|
|
return [
|
|
kwargs.get("api_key"),
|
|
_api_key_from_env(kwargs.get("api_key_env")),
|
|
]
|
|
|
|
|
|
def _run(
|
|
awaitable: Awaitable[dict[str, Any]],
|
|
*,
|
|
output_json: bool,
|
|
secrets: list[str | None],
|
|
) -> None:
|
|
try:
|
|
payload = asyncio.run(awaitable)
|
|
except MxGatewayError as error:
|
|
raise click.ClickException(redact_secret(str(error), secrets)) from error
|
|
|
|
_emit(payload, output_json=output_json)
|
|
|
|
|
|
def _emit(
|
|
payload: dict[str, Any],
|
|
*,
|
|
output_json: bool,
|
|
text: str | None = None,
|
|
) -> None:
|
|
if output_json:
|
|
click.echo(json.dumps(payload, sort_keys=True))
|
|
return
|
|
|
|
click.echo(text or json.dumps(payload, sort_keys=True))
|
|
|
|
|
|
async def _collect_events(
|
|
events: Any,
|
|
*,
|
|
max_events: int,
|
|
timeout: float,
|
|
) -> list[pb.MxEvent]:
|
|
if max_events > MAX_AGGREGATE_EVENTS:
|
|
raise click.BadParameter(
|
|
f"must be less than or equal to {MAX_AGGREGATE_EVENTS}",
|
|
param_hint="--max-events",
|
|
)
|
|
|
|
collected: list[pb.MxEvent] = []
|
|
iterator = events.__aiter__()
|
|
try:
|
|
while len(collected) < max_events:
|
|
collected.append(await asyncio.wait_for(iterator.__anext__(), timeout=timeout))
|
|
except StopAsyncIteration:
|
|
pass
|
|
except asyncio.TimeoutError:
|
|
pass
|
|
finally:
|
|
close = getattr(iterator, "aclose", None)
|
|
if close is not None:
|
|
await close()
|
|
return collected
|
|
|
|
|
|
async def _collect_alarm_messages(
|
|
messages: Any,
|
|
*,
|
|
max_messages: int,
|
|
timeout: float,
|
|
) -> list[pb.AlarmFeedMessage]:
|
|
if max_messages > MAX_AGGREGATE_EVENTS:
|
|
raise click.BadParameter(
|
|
f"must be less than or equal to {MAX_AGGREGATE_EVENTS}",
|
|
param_hint="--max-messages",
|
|
)
|
|
|
|
collected: list[pb.AlarmFeedMessage] = []
|
|
iterator = messages.__aiter__()
|
|
try:
|
|
while len(collected) < max_messages:
|
|
collected.append(await asyncio.wait_for(iterator.__anext__(), timeout=timeout))
|
|
except StopAsyncIteration:
|
|
pass
|
|
except asyncio.TimeoutError:
|
|
pass
|
|
finally:
|
|
close = getattr(iterator, "aclose", None)
|
|
if close is not None:
|
|
await close()
|
|
return collected
|
|
|
|
|
|
async def _collect_deploy_events(
|
|
events: Any,
|
|
*,
|
|
max_events: int,
|
|
timeout: float,
|
|
) -> list[galaxy_pb.DeployEvent]:
|
|
if max_events > MAX_AGGREGATE_EVENTS:
|
|
raise click.BadParameter(
|
|
f"must be less than or equal to {MAX_AGGREGATE_EVENTS}",
|
|
param_hint="--max-events",
|
|
)
|
|
|
|
collected: list[galaxy_pb.DeployEvent] = []
|
|
iterator = events.__aiter__()
|
|
try:
|
|
while len(collected) < max_events:
|
|
collected.append(await asyncio.wait_for(iterator.__anext__(), timeout=timeout))
|
|
except StopAsyncIteration:
|
|
pass
|
|
except asyncio.TimeoutError:
|
|
pass
|
|
finally:
|
|
close = getattr(iterator, "aclose", None)
|
|
if close is not None:
|
|
await close()
|
|
return collected
|
|
|
|
|
|
def _parse_value(raw_value: str, value_type: str) -> MxValueInput:
|
|
normalized = value_type.lower()
|
|
if normalized == "bool":
|
|
return raw_value.lower() in ("1", "true", "yes", "on")
|
|
if normalized in ("int", "int32", "int64"):
|
|
return int(raw_value)
|
|
if normalized in ("float", "double"):
|
|
return float(raw_value)
|
|
if normalized in ("time", "timestamp"):
|
|
return _parse_datetime(raw_value)
|
|
if normalized == "raw":
|
|
return raw_value.encode("utf-8")
|
|
if normalized == "string":
|
|
return raw_value
|
|
raise click.BadParameter(f"unsupported value type: {value_type}", param_hint="--type")
|
|
|
|
|
|
def _parse_datetime(raw_value: str) -> datetime:
|
|
if raw_value.endswith("Z"):
|
|
raw_value = raw_value[:-1] + "+00:00"
|
|
parsed = datetime.fromisoformat(raw_value)
|
|
if parsed.tzinfo is None:
|
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
return parsed
|
|
|
|
|
|
def _parse_string_list(raw_value: str) -> list[str]:
|
|
values = [item.strip() for item in raw_value.split(",") if item.strip()]
|
|
if not values:
|
|
raise click.BadParameter("at least one item is required", param_hint="--items")
|
|
return values
|
|
|
|
|
|
def _parse_int_list(raw_value: str) -> list[int]:
|
|
values = [item.strip() for item in raw_value.split(",") if item.strip()]
|
|
if not values:
|
|
raise click.BadParameter("at least one item handle is required", param_hint="--item-handles")
|
|
return [int(item) for item in values]
|
|
|
|
|
|
def _message_dict(message: Any) -> dict[str, Any]:
|
|
return MessageToDict(
|
|
message,
|
|
preserving_proto_field_name=False,
|
|
use_integers_for_enums=False,
|
|
)
|