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