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
+2 -2
View File
@@ -198,8 +198,8 @@ mxgw-py register --session-id <id> --client-name python-client --json
mxgw-py add-item --session-id <id> --server-handle 1 --item Object.Attribute --json
mxgw-py advise --session-id <id> --server-handle 1 --item-handle 2 --json
mxgw-py stream-events --session-id <id> --max-events 1 --json
mxgw-py stream-alarms --session-id <id> --max-messages 1 --json
mxgw-py acknowledge-alarm --session-id <id> --alarm-reference "\\Galaxy\Area001.Pump001.PumpFault" --json
mxgw-py stream-alarms --max-messages 1 --json
mxgw-py acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --json
mxgw-py write --session-id <id> --server-handle 1 --item-handle 2 --type int32 --value 123 --json
```
@@ -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:
@@ -0,0 +1,789 @@
"""Regression tests for Client.Python-022..026.
Each test corresponds to a finding from the latest re-review. Tests are
TDD-first — they failed against the pre-fix source and pass against the
fixed source.
"""
from __future__ import annotations
import json
import re
import time as _time_module_ref
from pathlib import Path
from typing import Any
import pytest
from click.testing import CliRunner
from zb_mom_ww_mxgateway import ClientOptions, GatewayClient
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
from zb_mom_ww_mxgateway_cli import commands as cli_commands
from zb_mom_ww_mxgateway_cli.commands import _use_plaintext, main
_BATCH_EOR = "__MXGW_BATCH_EOR__"
# ---------------------------------------------------------------------------
# Client.Python-022 — README CLI examples must parse against the implementation.
# ---------------------------------------------------------------------------
def _readme_path() -> Path:
return Path(__file__).resolve().parent.parent / "README.md"
def _extract_mxgw_py_examples() -> list[list[str]]:
"""Return the README's ``mxgw-py ...`` example lines as click arg lists.
Replaces angle-bracket placeholders (``<id>``) with safe stub values and
leaves real flag names untouched. The returned arg lists drop the
``mxgw-py`` prefix.
"""
text = _readme_path().read_text(encoding="utf-8")
args: list[list[str]] = []
for raw_line in text.splitlines():
line = raw_line.strip()
if not line.startswith("mxgw-py "):
continue
# Strip the leading "mxgw-py " token.
body = line[len("mxgw-py ") :]
# Replace common placeholders so click does not error on the placeholder.
body = body.replace("<id>", "session-1")
# Backtick-quoted hostnames in the TLS example are not represented
# in CLI; safe to leave as-is.
tokens = _split_cli_tokens(body)
# Keep only examples that exercise a real subcommand. Skip TLS
# multi-flag example (we only need the README CLI examples added in
# commits 8738735 — stream-alarms / acknowledge-alarm).
args.append(tokens)
return args
def _split_cli_tokens(body: str) -> list[str]:
"""Split a CLI body into argv tokens, honouring double-quoted strings."""
tokens: list[str] = []
pattern = re.compile(r'"([^"]*)"|(\S+)')
for match in pattern.finditer(body):
quoted, plain = match.group(1), match.group(2)
tokens.append(quoted if quoted is not None else plain)
return tokens
def test_readme_alarm_examples_parse_against_cli() -> None:
"""README `stream-alarms` / `acknowledge-alarm` examples must parse without
triggering Click's ``no such option`` error.
Drives every README ``mxgw-py`` example through Click's ``--help`` style
parser by re-invoking the documented argv with a trailing ``--help`` flag so
only the parser runs (no RPC is attempted). If a documented flag does not
exist on the subcommand, Click prints ``no such option: --<flag>`` and
exits 2 — that is the regression we want to catch.
"""
runner = CliRunner()
examples = _extract_mxgw_py_examples()
assert any(
"stream-alarms" in args for args in examples
), "README must include a stream-alarms example."
assert any(
"acknowledge-alarm" in args for args in examples
), "README must include an acknowledge-alarm example."
for argv in examples:
# Strip "--json" (already a real flag) and any value-bearing flag that
# requires a host/file/value, then append --help so we exercise the
# parser only.
# We just append --help — Click parses all options up to --help and
# then prints help; an unknown option still errors out first.
result = runner.invoke(main, [*argv, "--help"])
# Either help text printed (exit 0) or some other parser issue (exit 2);
# we only want to assert NO "no such option" error.
assert "no such option" not in result.output.lower(), (
f"README example failed Click parsing: argv={argv!r}\n"
f"output={result.output!r}"
)
# ---------------------------------------------------------------------------
# Client.Python-023 — REGRESSION of Client.Python-013. _use_plaintext must
# not silently auto-downgrade on localhost / 127.0.0.1.
# ---------------------------------------------------------------------------
def test_use_plaintext_does_not_auto_downgrade_for_localhost_endpoint() -> None:
"""A bare ``localhost:...`` endpoint with no flags must default to TLS."""
assert _use_plaintext({
"endpoint": "localhost:5001",
"plaintext": False,
"use_tls": False,
}) is False
def test_use_plaintext_does_not_auto_downgrade_for_loopback_ipv4_endpoint() -> None:
"""A bare ``127.0.0.1:...`` endpoint with no flags must default to TLS."""
assert _use_plaintext({
"endpoint": "127.0.0.1:5001",
"plaintext": False,
"use_tls": False,
}) is False
def test_use_plaintext_requires_explicit_plaintext_flag() -> None:
"""``--plaintext`` is the only way to opt in."""
assert _use_plaintext({
"endpoint": "localhost:5001",
"plaintext": True,
"use_tls": False,
}) is True
def test_use_plaintext_tls_flag_explicitly_disables_plaintext() -> None:
"""``--tls`` is accepted as an explicit affirmation of the default."""
assert _use_plaintext({
"endpoint": "localhost:5001",
"plaintext": False,
"use_tls": True,
}) is False
def test_use_plaintext_rejects_plaintext_and_tls_combined() -> None:
"""``--plaintext`` and ``--tls`` together must be rejected as ambiguous."""
import click as _click
with pytest.raises(_click.UsageError):
_use_plaintext({
"endpoint": "localhost:5001",
"plaintext": True,
"use_tls": True,
})
def test_cli_localhost_endpoint_with_no_flags_uses_tls_channel(monkeypatch) -> None:
"""End-to-end CLI: against ``localhost:...`` with no flags, the resolved
``ClientOptions.plaintext`` flowing into ``GatewayClient.connect`` must be
``False`` (TLS), so the API key bearer cannot leak over plaintext.
"""
captured: dict[str, Any] = {}
class _FakeStub:
def __init__(self) -> None:
pass
async def OpenSession(self, request: Any, *, metadata: tuple[Any, ...]) -> Any:
captured["metadata"] = metadata
return pb.OpenSessionReply(
session_id="session-1",
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
)
real_connect = GatewayClient.connect
@classmethod
async def _spy_connect(cls, options: ClientOptions, **kwargs: Any) -> GatewayClient:
captured["options"] = options
return await real_connect(options, stub=_FakeStub())
monkeypatch.setattr(GatewayClient, "connect", _spy_connect)
runner = CliRunner()
result = runner.invoke(
main,
[
"open-session",
"--endpoint",
"localhost:5000",
"--api-key",
"mxgw_test_secret",
"--json",
],
)
assert result.exit_code == 0, result.output
assert "options" in captured
assert captured["options"].plaintext is False, (
"localhost endpoint without --plaintext must NOT auto-downgrade to plaintext"
)
# ---------------------------------------------------------------------------
# Client.Python-024 — `batch` must not use CliRunner from production code,
# and a recursive `batch` line must not silently re-enter.
# ---------------------------------------------------------------------------
def test_batch_command_does_not_use_clirunner_in_production() -> None:
"""`commands.py` must not import or instantiate the test-only CliRunner helper.
Docstring references explaining what the module deliberately avoids are
permitted; what is forbidden is an actual ``import`` of ``click.testing``
or an actual ``CliRunner()`` instantiation in executable code.
"""
source = Path(cli_commands.__file__).read_text(encoding="utf-8")
assert "from click.testing" not in source, (
"click.testing is a test-only helper and must not be used by production code"
)
assert "import click.testing" not in source, (
"click.testing is a test-only helper and must not be used by production code"
)
# `CliRunner()` (instantiation) must not appear in production code.
assert "CliRunner(" not in source, (
"CliRunner() must not be instantiated in production code"
)
def test_batch_recursive_batch_line_is_bounded() -> None:
"""A `batch` line nested inside `batch` stdin must not be silently spawned.
The pre-fix implementation re-invoked the test runner with empty stdin,
so `batch` inside `batch` exited cleanly with no error. The fix either
rejects the nested invocation or surfaces it as an error block so the
behaviour is auditable.
"""
runner = CliRunner()
result = runner.invoke(
main,
["batch"],
input="batch\nversion --json\n",
)
# Outer batch must still exit 0 and process both lines.
assert result.exit_code == 0
assert result.output.count(_BATCH_EOR) == 2
blocks = [block for block in result.output.split(_BATCH_EOR + "\n") if block]
# The first block — the recursive `batch` line — must surface an error
# JSON. (Either an explicit rejection, or some non-empty error block —
# NOT a silently empty block.)
first_block = blocks[0].strip()
assert first_block, "recursive batch line must not be silently swallowed"
payload = json.loads(first_block.splitlines()[-1])
assert "error" in payload, (
f"recursive batch line should surface an error: got {payload!r}"
)
# ---------------------------------------------------------------------------
# Client.Python-025 — Behavioural tests for new bulk SDK methods,
# stream_alarms, and the new CLI subcommands.
# ---------------------------------------------------------------------------
class _AlarmFakeStream:
def __init__(self, messages: list[pb.AlarmFeedMessage]) -> None:
self._messages = list(messages)
self.cancelled = False
def __aiter__(self) -> "_AlarmFakeStream":
return self
async def __anext__(self) -> pb.AlarmFeedMessage:
if not self._messages:
raise StopAsyncIteration
return self._messages.pop(0)
def cancel(self) -> None:
self.cancelled = True
class _BulkFakeUnary:
def __init__(self, replies: list[Any]) -> None:
self.replies = replies
self.requests: list[Any] = []
self.metadata: tuple[tuple[str, str], ...] | None = None
async def __call__(self, request: Any, *, metadata: tuple[tuple[str, str], ...]) -> Any:
self.requests.append(request)
self.metadata = metadata
return self.replies.pop(0)
class _BulkFakeStub:
def __init__(self) -> None:
self.open_session = _BulkFakeUnary(
[
pb.OpenSessionReply(
session_id="session-1",
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
),
],
)
self.invoke = _BulkFakeUnary([])
self.OpenSession = self.open_session
self.Invoke = self.invoke
self.stream_alarms_metadata: tuple[tuple[str, str], ...] | None = None
self._alarm_stream = _AlarmFakeStream([])
def set_invoke_replies(self, replies: list[Any]) -> None:
self.invoke.replies = replies
def set_alarm_stream(self, stream: _AlarmFakeStream) -> None:
self._alarm_stream = stream
def StreamAlarms(self, request: Any, *, metadata: tuple[tuple[str, str], ...]) -> Any:
self.stream_alarms_request = request
self.stream_alarms_metadata = metadata
return self._alarm_stream
@pytest.mark.asyncio
async def test_session_read_bulk_sends_expected_request_shape() -> None:
stub = _BulkFakeStub()
stub.set_invoke_replies(
[
pb.MxCommandReply(
session_id="session-1",
kind=pb.MX_COMMAND_KIND_READ_BULK,
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
read_bulk=pb.BulkReadReply(
results=[
pb.BulkReadResult(
tag_address="Tank01.Level",
was_successful=True,
),
],
),
),
],
)
client = await GatewayClient.connect(
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
stub=stub,
)
session = await client.open_session()
results = await session.read_bulk(12, ["Tank01.Level"], timeout_ms=1500)
assert len(results) == 1
assert results[0].tag_address == "Tank01.Level"
request = stub.invoke.requests[0]
assert request.command.kind == pb.MX_COMMAND_KIND_READ_BULK
assert request.command.read_bulk.server_handle == 12
assert list(request.command.read_bulk.tag_addresses) == ["Tank01.Level"]
assert request.command.read_bulk.timeout_ms == 1500
@pytest.mark.asyncio
async def test_session_write_bulk_sends_expected_request_shape() -> None:
stub = _BulkFakeStub()
stub.set_invoke_replies(
[
pb.MxCommandReply(
session_id="session-1",
kind=pb.MX_COMMAND_KIND_WRITE_BULK,
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
write_bulk=pb.BulkWriteReply(
results=[pb.BulkWriteResult(was_successful=True)],
),
),
],
)
client = await GatewayClient.connect(
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
stub=stub,
)
session = await client.open_session()
from zb_mom_ww_mxgateway.values import to_mx_value
entries = [
pb.WriteBulkEntry(item_handle=34, user_id=99, value=to_mx_value(123)),
]
results = await session.write_bulk(12, entries)
assert results[0].was_successful is True
cmd = stub.invoke.requests[0].command
assert cmd.kind == pb.MX_COMMAND_KIND_WRITE_BULK
assert cmd.write_bulk.server_handle == 12
assert cmd.write_bulk.entries[0].item_handle == 34
assert cmd.write_bulk.entries[0].user_id == 99
@pytest.mark.asyncio
async def test_session_write2_bulk_sends_expected_request_shape() -> None:
stub = _BulkFakeStub()
stub.set_invoke_replies(
[
pb.MxCommandReply(
session_id="session-1",
kind=pb.MX_COMMAND_KIND_WRITE2_BULK,
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
write2_bulk=pb.BulkWriteReply(
results=[pb.BulkWriteResult(was_successful=True)],
),
),
],
)
client = await GatewayClient.connect(
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
stub=stub,
)
session = await client.open_session()
from zb_mom_ww_mxgateway.values import to_mx_value
entries = [
pb.Write2BulkEntry(
item_handle=34,
user_id=99,
value=to_mx_value(123),
timestamp_value=to_mx_value(1.5),
),
]
results = await session.write2_bulk(12, entries)
assert results[0].was_successful is True
cmd = stub.invoke.requests[0].command
assert cmd.kind == pb.MX_COMMAND_KIND_WRITE2_BULK
assert cmd.write2_bulk.server_handle == 12
assert cmd.write2_bulk.entries[0].item_handle == 34
@pytest.mark.asyncio
async def test_session_write_secured_bulk_sends_expected_request_shape() -> None:
stub = _BulkFakeStub()
stub.set_invoke_replies(
[
pb.MxCommandReply(
session_id="session-1",
kind=pb.MX_COMMAND_KIND_WRITE_SECURED_BULK,
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
write_secured_bulk=pb.BulkWriteReply(
results=[pb.BulkWriteResult(was_successful=True)],
),
),
],
)
client = await GatewayClient.connect(
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
stub=stub,
)
session = await client.open_session()
from zb_mom_ww_mxgateway.values import to_mx_value
entries = [
pb.WriteSecuredBulkEntry(
item_handle=34,
current_user_id=42,
verifier_user_id=43,
value=to_mx_value("secret"),
),
]
results = await session.write_secured_bulk(12, entries)
assert results[0].was_successful is True
cmd = stub.invoke.requests[0].command
assert cmd.kind == pb.MX_COMMAND_KIND_WRITE_SECURED_BULK
assert cmd.write_secured_bulk.server_handle == 12
assert cmd.write_secured_bulk.entries[0].current_user_id == 42
assert cmd.write_secured_bulk.entries[0].verifier_user_id == 43
@pytest.mark.asyncio
async def test_session_write_secured2_bulk_sends_expected_request_shape() -> None:
stub = _BulkFakeStub()
stub.set_invoke_replies(
[
pb.MxCommandReply(
session_id="session-1",
kind=pb.MX_COMMAND_KIND_WRITE_SECURED2_BULK,
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
write_secured2_bulk=pb.BulkWriteReply(
results=[pb.BulkWriteResult(was_successful=True)],
),
),
],
)
client = await GatewayClient.connect(
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
stub=stub,
)
session = await client.open_session()
from zb_mom_ww_mxgateway.values import to_mx_value
entries = [
pb.WriteSecured2BulkEntry(
item_handle=34,
current_user_id=42,
verifier_user_id=43,
value=to_mx_value("secret"),
timestamp_value=to_mx_value(1.5),
),
]
results = await session.write_secured2_bulk(12, entries)
assert results[0].was_successful is True
cmd = stub.invoke.requests[0].command
assert cmd.kind == pb.MX_COMMAND_KIND_WRITE_SECURED2_BULK
assert cmd.write_secured2_bulk.entries[0].current_user_id == 42
@pytest.mark.asyncio
async def test_stream_alarms_yields_feed_messages_and_cancels_on_close() -> None:
transitions = [
pb.AlarmFeedMessage(
transition=pb.OnAlarmTransitionEvent(
alarm_full_reference="Tank01.Level.HiHi",
transition_kind=pb.ALARM_TRANSITION_KIND_RAISE,
),
),
]
stream = _AlarmFakeStream(transitions)
stub = _BulkFakeStub()
stub.set_alarm_stream(stream)
client = await GatewayClient.connect(
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
stub=stub,
)
iterator = client.stream_alarms(pb.StreamAlarmsRequest(alarm_filter_prefix="Tank01."))
first = await anext(iterator)
await iterator.aclose()
assert first.transition.alarm_full_reference == "Tank01.Level.HiHi"
assert stream.cancelled
assert stub.stream_alarms_metadata == (("authorization", "Bearer mxgw_test_secret"),)
assert stub.stream_alarms_request.alarm_filter_prefix == "Tank01."
# ---- CLI happy-path coverage for the new subcommands ----
def _install_fake_connect(monkeypatch, stub: Any) -> dict[str, Any]:
"""Patch `GatewayClient.connect` so the CLI uses the supplied fake stub."""
captured: dict[str, Any] = {}
real_connect = GatewayClient.connect
@classmethod
async def _spy_connect(cls, options: ClientOptions, **kwargs: Any) -> GatewayClient:
captured["options"] = options
return await real_connect(options, stub=stub)
monkeypatch.setattr(GatewayClient, "connect", _spy_connect)
return captured
def test_cli_read_bulk_happy_path(monkeypatch) -> None:
stub = _BulkFakeStub()
stub.set_invoke_replies(
[
pb.MxCommandReply(
session_id="session-1",
kind=pb.MX_COMMAND_KIND_READ_BULK,
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
read_bulk=pb.BulkReadReply(
results=[
pb.BulkReadResult(
tag_address="Tank01.Level",
was_successful=True,
),
],
),
),
],
)
_install_fake_connect(monkeypatch, stub)
runner = CliRunner()
result = runner.invoke(
main,
[
"read-bulk",
"--endpoint",
"localhost:5000",
"--plaintext",
"--session-id",
"session-1",
"--server-handle",
"12",
"--items",
"Tank01.Level",
"--timeout-ms",
"1500",
"--json",
],
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["results"][0]["tagAddress"] == "Tank01.Level"
def test_cli_write_bulk_happy_path(monkeypatch) -> None:
stub = _BulkFakeStub()
stub.set_invoke_replies(
[
pb.MxCommandReply(
session_id="session-1",
kind=pb.MX_COMMAND_KIND_WRITE_BULK,
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
write_bulk=pb.BulkWriteReply(
results=[pb.BulkWriteResult(was_successful=True)],
),
),
],
)
_install_fake_connect(monkeypatch, stub)
runner = CliRunner()
result = runner.invoke(
main,
[
"write-bulk",
"--endpoint",
"localhost:5000",
"--plaintext",
"--session-id",
"session-1",
"--server-handle",
"12",
"--item-handles",
"34",
"--values",
"123",
"--type",
"int32",
"--json",
],
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["results"][0]["wasSuccessful"] is True
cmd = stub.invoke.requests[0].command
assert cmd.kind == pb.MX_COMMAND_KIND_WRITE_BULK
def test_cli_stream_alarms_happy_path(monkeypatch) -> None:
transitions = [
pb.AlarmFeedMessage(
transition=pb.OnAlarmTransitionEvent(
alarm_full_reference="Tank01.Level.HiHi",
transition_kind=pb.ALARM_TRANSITION_KIND_RAISE,
),
),
]
stream = _AlarmFakeStream(transitions)
stub = _BulkFakeStub()
stub.set_alarm_stream(stream)
_install_fake_connect(monkeypatch, stub)
runner = CliRunner()
result = runner.invoke(
main,
[
"stream-alarms",
"--endpoint",
"localhost:5000",
"--plaintext",
"--max-messages",
"1",
"--timeout",
"5.0",
"--filter-prefix",
"Tank01.",
"--json",
],
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["messages"][0]["transition"]["alarmFullReference"] == "Tank01.Level.HiHi"
def test_cli_acknowledge_alarm_happy_path(monkeypatch) -> None:
stub = _BulkFakeStub()
stub.acknowledge_alarm = _BulkFakeUnary(
[
pb.AcknowledgeAlarmReply(
correlation_id="corr-1",
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
status=pb.MxStatusProxy(success=1, category=pb.MX_STATUS_CATEGORY_OK),
),
],
)
stub.AcknowledgeAlarm = stub.acknowledge_alarm
_install_fake_connect(monkeypatch, stub)
runner = CliRunner()
result = runner.invoke(
main,
[
"acknowledge-alarm",
"--endpoint",
"localhost:5000",
"--plaintext",
"--reference",
"Tank01.Level.HiHi",
"--comment",
"investigating",
"--operator",
"alice",
"--json",
],
)
assert result.exit_code == 0, result.output
captured_request = stub.acknowledge_alarm.requests[0]
assert captured_request.alarm_full_reference == "Tank01.Level.HiHi"
assert captured_request.comment == "investigating"
assert captured_request.operator_user == "alice"
# ---------------------------------------------------------------------------
# Client.Python-026 — `import time` at module scope; tighter cleanup excepts.
# ---------------------------------------------------------------------------
def test_commands_module_imports_time_at_module_scope() -> None:
"""`time` must be imported at module scope, not inside `_bench_read_bulk`.
`inspect.getsource(_bench_read_bulk)` must not contain a function-local
``import time`` statement.
"""
import inspect
source = inspect.getsource(cli_commands._bench_read_bulk)
# The function body must NOT contain a function-local `import time` line.
for line in source.splitlines():
stripped = line.strip()
assert stripped != "import time", (
f"_bench_read_bulk must not have function-local `import time`: {line!r}"
)
# And the module-level `time` attribute must be present.
assert hasattr(cli_commands, "time"), (
"`time` must be imported at module scope on commands.py"
)
assert cli_commands.time is _time_module_ref
def test_commands_module_bench_read_bulk_does_not_use_bare_except_pass() -> None:
"""The two `except Exception: pass` cleanup blocks in `_bench_read_bulk`
must be removed in favour of either logging or a narrower exception class.
"""
import inspect
source = inspect.getsource(cli_commands._bench_read_bulk)
# Reject the bare `except Exception:` followed by `pass` pattern in
# `_bench_read_bulk`. We tolerate `except Exception as <name>:` because the
# fix logs the exception.
pattern = re.compile(r"except\s+Exception\s*:\s*\n\s*pass\b")
assert not pattern.search(source), (
"_bench_read_bulk cleanup blocks must log or narrow the except clause"
)