Python client: port bulk read/write SDK methods + CLI subcommands

Mirrors the .NET / Go ports of divergent branch commit f220908. HEAD's
Session class had only the subscribe-style bulks; this commit adds the
value-bulk SDK surface plus matching CLI subcommands and a
bench-read-bulk harness.

SDK (zb_mom_ww_mxgateway/session.py):
- async def write_bulk(server_handle, entries, *, correlation_id="")
  → list[pb.BulkWriteResult]
- async def write2_bulk(server_handle, entries, *, correlation_id="")
  → list[pb.BulkWriteResult]
- async def write_secured_bulk(server_handle, entries, *, correlation_id="")
  → list[pb.BulkWriteResult]
- async def write_secured2_bulk(server_handle, entries, *, correlation_id="")
  → list[pb.BulkWriteResult]
- async def read_bulk(server_handle, tag_addresses, *, timeout_ms=0,
  correlation_id="") → list[pb.BulkReadResult]

All five reuse the existing _ensure_bulk_size validator and route
through the existing invoke() pipeline. read_bulk additionally enforces
timeout_ms >= 0.

CLI (zb_mom_ww_mxgateway_cli/commands.py):
- read-bulk / write-bulk / write2-bulk / write-secured-bulk /
  write-secured2-bulk registered as click @main.command(...). The
  write families share a _build_write_bulk_entries() helper that parses
  --item-handles and --values with a single --type, validates count
  match, converts via to_mx_value, and assembles the correct per-entry
  proto message.
- bench-read-bulk: opens its own session, subscribes to --bulk-size
  TestMachine_NNN.TestChangingInt tags, runs warmup then steady-state
  ReadBulk for --duration-seconds with time.perf_counter() latency
  capture, and emits the shared JSON schema (language, durationMs,
  totalCalls, successfulCalls, failedCalls, totalReadResults,
  cachedReadResults, callsPerSecond, latencyMs:{p50,p95,p99,max,mean})
  so scripts/bench-read-bulk.ps1 collates Python alongside the four
  other clients. _percentile_summary + linear-interpolation
  _percentile helper match the Go / .NET implementations.

to_mx_value is added to the existing values-module import line in
commands.py since the bulk-write commands need it.

Verification: python -m pip install -e . --quiet --no-deps; pytest
42/42 passing. Manual smoke against live gateway on localhost:5120:
open-session → register → subscribe-bulk on two
TestMachine_NNN.TestChangingInt tags (both wasSuccessful=true) →
read-bulk (both wasSuccessful=true / wasCached=true / int32 values
present) → close-session SESSION_STATE_CLOSED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-24 04:50:10 -04:00
parent 325106920f
commit 6add4b4acc
2 changed files with 466 additions and 1 deletions
@@ -334,6 +334,138 @@ class Session:
)
return list(reply.unsubscribe_bulk.results)
async def write_bulk(
self,
server_handle: int,
entries: Sequence[pb.WriteBulkEntry],
*,
correlation_id: str = "",
) -> list[pb.BulkWriteResult]:
"""Invoke MXAccess `WriteBulk` and return one BulkWriteResult per entry.
Per-entry MXAccess failures appear as results with ``was_successful = False``
and a populated ``error_message`` / ``hresult``; this method does not raise
on per-entry failure, mirroring the existing add/advise bulk surface.
"""
if entries is None:
raise TypeError("entries is required")
_ensure_bulk_size("entries", len(entries))
reply = await self.invoke(
pb.MxCommand(
kind=pb.MX_COMMAND_KIND_WRITE_BULK,
write_bulk=pb.WriteBulkCommand(
server_handle=server_handle,
entries=entries,
),
),
correlation_id=correlation_id,
)
return list(reply.write_bulk.results)
async def write2_bulk(
self,
server_handle: int,
entries: Sequence[pb.Write2BulkEntry],
*,
correlation_id: str = "",
) -> list[pb.BulkWriteResult]:
"""Invoke MXAccess `Write2Bulk` (timestamped) and return per-entry results."""
if entries is None:
raise TypeError("entries is required")
_ensure_bulk_size("entries", len(entries))
reply = await self.invoke(
pb.MxCommand(
kind=pb.MX_COMMAND_KIND_WRITE2_BULK,
write2_bulk=pb.Write2BulkCommand(
server_handle=server_handle,
entries=entries,
),
),
correlation_id=correlation_id,
)
return list(reply.write2_bulk.results)
async def write_secured_bulk(
self,
server_handle: int,
entries: Sequence[pb.WriteSecuredBulkEntry],
*,
correlation_id: str = "",
) -> list[pb.BulkWriteResult]:
"""Invoke MXAccess `WriteSecuredBulk` — credential-sensitive values must not be logged."""
if entries is None:
raise TypeError("entries is required")
_ensure_bulk_size("entries", len(entries))
reply = await self.invoke(
pb.MxCommand(
kind=pb.MX_COMMAND_KIND_WRITE_SECURED_BULK,
write_secured_bulk=pb.WriteSecuredBulkCommand(
server_handle=server_handle,
entries=entries,
),
),
correlation_id=correlation_id,
)
return list(reply.write_secured_bulk.results)
async def write_secured2_bulk(
self,
server_handle: int,
entries: Sequence[pb.WriteSecured2BulkEntry],
*,
correlation_id: str = "",
) -> list[pb.BulkWriteResult]:
"""Invoke MXAccess `WriteSecured2Bulk` (timestamped + verified)."""
if entries is None:
raise TypeError("entries is required")
_ensure_bulk_size("entries", len(entries))
reply = await self.invoke(
pb.MxCommand(
kind=pb.MX_COMMAND_KIND_WRITE_SECURED2_BULK,
write_secured2_bulk=pb.WriteSecured2BulkCommand(
server_handle=server_handle,
entries=entries,
),
),
correlation_id=correlation_id,
)
return list(reply.write_secured2_bulk.results)
async def read_bulk(
self,
server_handle: int,
tag_addresses: Sequence[str],
*,
timeout_ms: int = 0,
correlation_id: str = "",
) -> list[pb.BulkReadResult]:
"""Invoke `ReadBulk` — snapshot the current value of each requested tag.
MXAccess COM has no synchronous read; the worker returns the cached
``OnDataChange`` value for any tag that is already advised (``was_cached =
True``) without modifying the existing subscription, and falls back to
a full AddItem + Advise + wait + UnAdvise + RemoveItem snapshot lifecycle
otherwise. ``timeout_ms`` bounds the per-tag wait in the snapshot case;
pass ``0`` to use the worker default (1000 ms).
"""
if tag_addresses is None:
raise TypeError("tag_addresses is required")
_ensure_bulk_size("tag_addresses", len(tag_addresses))
if timeout_ms < 0:
raise ValueError("timeout_ms must be non-negative")
reply = await self.invoke(
pb.MxCommand(
kind=pb.MX_COMMAND_KIND_READ_BULK,
read_bulk=pb.ReadBulkCommand(
server_handle=server_handle,
tag_addresses=tag_addresses,
timeout_ms=timeout_ms,
),
),
correlation_id=correlation_id,
)
return list(reply.read_bulk.results)
async def write(
self,
server_handle: int,