Resolve Client.Python-022..026: TLS-by-default, batch CLI, README

Client.Python-022  README CLI examples for stream-alarms and
                   acknowledge-alarm now use the correct flags;
                   regression test parses every documented line through
                   Click.
Client.Python-023  Re-applied Client.Python-013 — _use_plaintext drops
                   the silent localhost / 127.0.0.1 auto-downgrade
                   branch; --plaintext and --tls are mutually exclusive
                   and TLS is the default.
Client.Python-024  batch dispatch routes through main.main(...,
                   standalone_mode=False) under a redirected stdout
                   instead of click.testing.CliRunner; recursive batch
                   lines are rejected outright.
Client.Python-025  Added behavioural tests for the five bulk SDK methods,
                   stream_alarms, and the new CLI subcommands.
Client.Python-026  _bench_read_bulk hoists 'import time' to module scope
                   and logs cleanup failures instead of swallowing them.

All resolved at 2026-05-24; python -m pytest is 65/65 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-24 08:50:27 -04:00
parent 4a0f88b17d
commit f5b50c4484
4 changed files with 1002 additions and 54 deletions
@@ -3,15 +3,18 @@
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 click.testing import CliRunner
from google.protobuf.json_format import MessageToDict
from zb_mom_ww_mxgateway import __version__
@@ -22,6 +25,8 @@ 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__"
@@ -56,9 +61,10 @@ def batch() -> None:
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": "..."}``.
"""
runner = CliRunner()
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")
@@ -68,44 +74,77 @@ def batch() -> None:
args = line.split()
try:
result = runner.invoke(main, args, catch_exceptions=True)
except Exception as exc: # noqa: BLE001 — be safe; never let batch loop die
_batch_write_error(exc.__class__.__name__, str(exc))
# 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
if result.exit_code == 0:
# Normal success — write captured output as-is.
sys.stdout.write(result.output)
_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:
# Something went wrong. If the command already emitted a JSON object
# (e.g. the output starts with '{'), trust that and relay it verbatim.
# Otherwise synthesise the standard {"error": ..., "type": ...} shape.
output = result.output or ""
exc = result.exception
msg = output.strip()
if msg.startswith("Error: "):
msg = msg[len("Error: "):]
_batch_write_error("CliError", msg)
if output.lstrip().startswith("{"):
# Already JSON — relay verbatim (may or may not end with newline).
sys.stdout.write(output)
if not output.endswith("\n"):
sys.stdout.write("\n")
elif exc is not None and not isinstance(exc, SystemExit):
_batch_write_error(type(exc).__name__, str(exc))
else:
# Click's default error format is "Error: <message>\n"; extract the
# message so the harness gets clean JSON.
msg = output.strip()
if msg.startswith("Error: "):
msg = msg[len("Error: "):]
exc_type = (
type(exc).__name__
if exc is not None and not isinstance(exc, SystemExit)
else "CliError"
)
_batch_write_error(exc_type, msg)
_batch_flush_eor()
_batch_flush_eor()
def _batch_write_error(exc_type: str, message: str) -> None:
@@ -673,7 +712,6 @@ async def _write_secured2_bulk(**kwargs: Any) -> dict[str, Any]:
async def _bench_read_bulk(**kwargs: Any) -> dict[str, Any]:
"""ReadBulk stress benchmark — matches the .NET / Go / Rust / Java schema."""
import time
bulk_size = int(kwargs["bulk_size"])
if bulk_size < 1:
@@ -730,12 +768,12 @@ async def _bench_read_bulk(**kwargs: Any) -> dict[str, Any]:
if item_handles:
try:
await session.unsubscribe_bulk(server_handle, item_handles)
except Exception:
pass
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:
pass
except Exception as exc: # noqa: BLE001 — bench is best-effort
logger.warning("bench-read-bulk: session.close cleanup failed: %s", exc)
return {
"language": "python",
@@ -899,11 +937,21 @@ def _session(client: GatewayClient, session_id: str):
def _use_plaintext(kwargs: dict[str, Any]) -> bool:
if kwargs.get("use_tls"):
return False
if kwargs.get("plaintext"):
return True
return kwargs["endpoint"].startswith("localhost:") or kwargs["endpoint"].startswith("127.0.0.1:")
"""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: