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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user