Files
mxaccessgw/clients/python
Joseph Doherty 1aafd6bde4 Code-review 2026-05-20 sweep #2: re-review at a020350, resolve 48 findings
Second re-review pass at commit a020350 caught 48 new findings — including
one High-severity regression I introduced in the prior sweep — and fixed
them all in one parallel wave.

High (1)
- Client.Python-018: prior sweep set `license = "Proprietary"` in
  pyproject.toml. setuptools >= 77 enforces PEP 639 and rejects the
  string (it must be a valid SPDX expression), so `pip wheel .` and
  `pip install -e .` both fail before any source compiles. Tests
  still pass because pytest bypasses the build backend via
  `pythonpath`. Dropped the invalid license string, kept the
  `License :: Other/Proprietary License` classifier, and added
  `tests/test_packaging.py` so a future regression of the same shape
  is caught in CI.

Mediums (6)
- Worker-023: `HeartbeatStuckCeiling` (default 75s = 5x HeartbeatGrace)
  on WorkerPipeSessionOptions bounds the in-flight-command watchdog
  suppression so a truly stuck COM call still triggers StaHung
  instead of permanently defeating the watchdog.
- Client.Rust-018: reverted Rust's `latencyMs` split so the
  cross-language bench comparison is apples-to-apples again;
  `failureLatencyMs` kept as Rust-only enrichment.
- Client.Java-021: applied Client.Java-002's terminal-state
  serialisation pattern to DeployEventStream so close() arriving
  after queue-overflow can't erase the overflow exception.
- IntegrationTests-017: teardown-parity test now uses a two-window
  stability check after UnAdvise instead of strict equality against
  the pre-UnAdvise count (which raced against in-flight events).
- IntegrationTests-019: new RecordingTestOutputHelper wraps every
  log sink the WriteSecured live test owns (worker stdout/stderr,
  gateway logs, direct WriteLine) so the credential is proven
  absent from the full output buffer, not just the diagnostic
  message.
- Tests-020: added MxAccessGatewayServiceConstraintTests coverage
  for the previously-uncovered Write2Bulk and WriteSecured2Bulk
  arms of WriteBulkConstraintPlan.SetPayload.

Lows (41 — highlights)
- Server: Galaxy glob cache eviction is race-free (Server-024);
  GalaxyRepositoryGrpcService takes IGalaxyRepository (Server-025);
  AlarmsOptions validated at startup (Server-026); Authorization.md
  Constraint Enforcement snippet/prose enumerate the bulk write/read
  family (Server-027); bulk-read-commands and bulk-write-commands
  capability tokens added to OpenSession (Server-029);
  NotWiredAlarmRpcDispatcher XML doc and missing scope-resolver and
  state-machine tests cleaned up (023, 028).
- Worker: AlarmCommandHandler now invokes the same STA-affinity
  guard the poll path uses, at every command entry (Worker-024);
  RunAsync null-checks the runtime-session factory result
  (Worker-025).
- Worker.Tests: shared LiveMxAccessOptInVariableName lives on
  GatewayContractInfo (Worker.Tests-025); MxAccessSession.CreateForTesting
  rejects production sinks (Worker.Tests-026); FakeRuntimeSession's
  CancelCommandReturnValue serialised under lock (Worker.Tests-027);
  Probes namespace lifted to MxGateway.Worker.Tests.Probes
  (Worker.Tests-029); cancel-envelope sequence numbers monotonised
  (Worker.Tests-030); docs/GatewayTesting.md gains a "Dev-rig Probes"
  section (Worker.Tests-028).
- Tests: ManualTimeProvider consolidated into one TestSupport/ copy
  (Tests-021); SessionManagerBulkTests adds a mid-flight cancellation
  test backed by a TaskCompletionSource fake (Tests-022); companion
  FakeWorkerProcess.WaitForExitAsync no longer fakes its exit signal
  (Tests-023); constraint plan reply-count divergence pinned
  (Tests-024).
- IntegrationTests: TryGetSession chain carries [MaybeNullWhen(false)]
  end-to-end (IntegrationTests-018); abnormal-exit keyword set
  tightened to pipe-disconnected/end-of-stream and the test now
  asserts streamTask.IsFaulted (020, 021).
- Client.Dotnet: bench commands added to isLongRunning so the
  default 30s wall-clock budget doesn't kill them (015);
  BenchStreamEventsAsync observes the inner stream task on every
  exit path (016).
- Client.Go: parseValue wraps strconv errors with flag context and
  %w (017); bench loops honour ctx.Done() (018); galaxy-watch parses
  RFC3339Nano with fractional seconds (019); runStreamEvents installs
  signal.NotifyContext like runGalaxyWatch (020); five new CLI-level
  table-driven tests cover the bulk/bench subcommands (021).
- Client.Java: toCompletable Javadoc rewritten to match the actual
  cancellation contract Client.Java-015 established (022); stream-events
  text path uses Long.toUnsignedString for worker_sequence (023);
  bench-read-bulk no longer pollutes success-latency histogram with
  failure durations (024); --shutdown-timeout CLI option propagates
  through to ClientOptions (025); seven new MxGatewayCliTests cover
  the bulk and bench commands (026).
- Client.Python: mxgateway_cli ships its own py.typed marker (019);
  wheel-build smoke test added under tests/test_packaging.py (020);
  README documents the Galaxy CLI parity gap explicitly (021).
- Client.Rust: RustClientDesign.md signatures match session.rs and
  document the AsRef<str> read_bulk genericism (019);
  next_correlation_id re-exported at the crate root, with a
  property-style doc contract and an explicit disclaimer that the
  literal textual format is not part of the contract (020).
- Contracts: BulkWriteResult comment names the actual
  IConstraintEnforcer mechanism instead of "tag-allowlist filter"
  (014); BulkReadResult gains explicit per-arm payload-population
  documentation for the success vs failure cases (015).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:28:54 -04:00
..

Python Client

The Python client package contains generated MXAccess Gateway protobuf bindings, the async mxgateway package, and the mxgw-py test CLI. The package uses the shared proto inputs documented in ../../docs/ClientProtoGeneration.md so gateway and client contracts stay in sync.

Layout

clients/python/
  pyproject.toml
  generate-proto.ps1
  src/mxgateway/
  src/mxgateway/generated/
  src/mxgateway_cli/
  tests/

src/mxgateway/generated contains code produced by grpc_tools.protoc. Do not edit generated files by hand.

Regenerating Protobuf Bindings

Run generation after the shared .proto files or the Python output path changes:

./generate-proto.ps1

The script uses the Python tool path recorded in ../../docs/ToolchainLinks.md.

Build And Test

Run the Python checks from clients/python:

python -m pip install -e ".[dev]"
python -m pytest
python -m pip wheel . --no-deps --wheel-dir "$env:TEMP\mxgateway-python-wheel"

The tests import the generated gateway and worker stubs, run fake async gateway stubs, verify API key metadata, exercise stream cancellation, load shared value and command fixtures, and check deterministic CLI output.

Packaging

Install the package in editable mode for local development:

python -m pip install -e ".[dev]"

Build a wheel from clients/python:

python -m pip wheel . --no-deps --wheel-dir "$env:TEMP\mxgateway-python-wheel"

Install the generated wheel into a target environment:

python -m pip install <wheel-path>

The wheel exposes the mxgw-py console script.

Library Usage

The library is async-first:

from mxgateway import GatewayClient

async with await GatewayClient.connect(
    endpoint="localhost:5000",
    api_key="<gateway-api-key>",
    plaintext=True,
) as client:
    session = await client.open_session(client_session_name="python-client")
    try:
        server_handle = await session.register("python-client")
        item_handle = await session.add_item(server_handle, "Object.Attribute")
        await session.advise(server_handle, item_handle)
    finally:
        await session.close()

GatewayClient.open_session_raw, GatewayClient.invoke_raw, and GatewayClient.stream_events_raw keep the generated protobuf replies and events available for parity tests. Session helpers call the method-specific MXAccess commands and preserve raw replies on typed command exceptions.

The full bulk family is available — add_item_bulk, advise_item_bulk, remove_item_bulk, unadvise_item_bulk, subscribe_bulk, unsubscribe_bulk, write_bulk, write2_bulk, write_secured_bulk, write_secured2_bulk, and read_bulk. Bulk methods carry a list of entries in one round-trip and return a list[pb.SubscribeResult] / list[pb.BulkWriteResult] / list[pb.BulkReadResult]; per-entry MXAccess failures appear as result entries with was_successful = False and never raise. read_bulk accepts a per-tag timeout_ms (0 = worker default) and returns cached OnDataChange values when the tag is already advised (was_cached = True) without touching the existing subscription.

*_raw methods (GatewayClient.invoke_raw, Session.invoke_raw) surface gateway protocol failures by raising the typed MxGateway* exceptions, but they deliberately do not run MXAccess-failure detection: an MXAccess HRESULT or MxStatusProxy status failure is left embedded in the returned reply and no MxAccessError is raised. Session.invoke adds that check on top. Parity-test callers using invoke_raw must inspect the reply's protocol_status, hresult, and statuses themselves. The non-raw Session helpers (register, add_item, write, the bulk methods, etc.) run the check and raise MxAccessError.

Value conversion (to_mx_value, used by Session.write/write2 and the bulk helpers) rejects non-finite floats — nan, inf, and -inf raise ValueError rather than being forwarded to MXAccess, which has no defined wire representation for them. Python bytes values are an opaque VT_RECORD pass-through that MXAccess does not interpret.

Canceling a Python task cancels the client-side gRPC call or stream wait. It does not abort an in-flight MXAccess COM call inside the worker process.

Galaxy Repository Browse

The GalaxyRepositoryClient wraps the read-only GalaxyRepository gRPC service. It lets callers test connectivity to the AVEVA System Platform Galaxy Repository (ZB SQL database), read the last deploy timestamp, and enumerate the deployed object hierarchy plus each object's dynamic attributes:

from mxgateway import GalaxyRepositoryClient

async with await GalaxyRepositoryClient.connect(
    endpoint="localhost:5000",
    api_key="<gateway-api-key>",
    plaintext=True,
) as galaxy:
    if not await galaxy.test_connection():
        raise RuntimeError("gateway cannot reach the Galaxy Repository DB")

    last_deploy = await galaxy.get_last_deploy_time()
    print(f"last deploy: {last_deploy}")

    for obj in await galaxy.discover_hierarchy():
        print(obj.tag_name, obj.contained_name)
        for attr in obj.attributes:
            print("  ", attr.attribute_name, "->", attr.full_tag_reference)

The methods return native Python types (bool, datetime | None, and a list[GalaxyObject] of generated proto messages) so callers can index into the hierarchy without learning the underlying stub class. The service requires the metadata:read scope on the API key.

discover_hierarchy buffers every object (with its full attribute list) into a single in-memory list. For a large Galaxy use iter_hierarchy instead — it is an async generator that fetches one page at a time and yields objects as they arrive, so peak memory stays bounded by a single page rather than the whole hierarchy:

async with await GalaxyRepositoryClient.connect(
    endpoint="localhost:5000",
    api_key="<gateway-api-key>",
    plaintext=True,
) as galaxy:
    async for obj in galaxy.iter_hierarchy():
        print(obj.tag_name, obj.contained_name)

Pages are fetched lazily: the next page is only requested once the caller has consumed every object from the current page.

Watching deploy events

GalaxyRepositoryClient.watch_deploy_events opens a server-streaming subscription that emits the current cached deploy state immediately and then one DeployEvent per new Galaxy deploy. sequence is monotonic per gateway start; gaps mean events were dropped from the per-subscriber buffer. Pass last_seen_deploy_time to suppress the bootstrap event when the caller already has the current state cached:

from datetime import datetime, timezone
from mxgateway import DeployEvent, GalaxyRepositoryClient

async with await GalaxyRepositoryClient.connect(
    endpoint="localhost:5000",
    api_key="<gateway-api-key>",
    plaintext=True,
) as galaxy:
    last_seen: datetime | None = None
    async for event in galaxy.watch_deploy_events(last_seen_deploy_time=last_seen):
        assert isinstance(event, DeployEvent)
        print(
            f"#{event.sequence} deploy={event.time_of_last_deploy.ToDatetime(tzinfo=timezone.utc)} "
            f"objects={event.object_count} attributes={event.attribute_count}"
        )
        if event.time_of_last_deploy_present:
            last_seen = event.time_of_last_deploy.ToDatetime(tzinfo=timezone.utc)

The method returns an async iterator yielding the generated DeployEvent proto. Breaking out of the loop, calling aclose() on the iterator, or cancelling the surrounding task closes the underlying gRPC stream cleanly. The streaming RPC requires the same metadata:read scope as the other Galaxy methods. The CLI does not currently expose a streaming watch-deploy-events subcommand — use the library API directly when subscribing to deploy events from Python.

Authentication And TLS

ClientOptions.api_key adds this metadata to unary calls and streams:

authorization: Bearer <api-key>

The client supports plaintext channels for local development, TLS with system roots, TLS with a custom ca_file, and an optional test server name override. API keys are redacted from option repr output and CLI error output.

The CLI defaults to TLS. Pass --plaintext explicitly to open an unencrypted channel — there is no implicit localhost downgrade. --tls is accepted but redundant with the default, and cannot be combined with --plaintext. Scripts that previously relied on a localhost: / 127.0.0.1: endpoint silently selecting plaintext must now pass --plaintext explicitly.

CLI

The CLI emits deterministic JSON for automation:

mxgw-py version --json
mxgw-py open-session --endpoint localhost:5000 --plaintext --json
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 write --session-id <id> --server-handle 1 --item-handle 2 --type int32 --value 123 --json

Use --api-key or --api-key-env MXGATEWAY_API_KEY to attach API key metadata. smoke opens a session, registers, adds an item, advises, streams a bounded event count, and closes the session in a finally block.

Use TLS options for a secured gateway:

mxgw-py smoke --endpoint mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item Object.Attribute --json

CLI Parity Gaps

The mxgw-py CLI does not currently ship the Galaxy Repository subcommands that the .NET (mxgw), Go (mxgw-go), Rust (mxgw), and Java (mxgw-java) CLIs expose:

  • galaxy-test-connection — ping the Galaxy Repository SQL DB.
  • galaxy-last-deploy — fetch the last deploy timestamp.
  • galaxy-discover — enumerate the deployed object hierarchy with attributes.
  • galaxy-watch — stream DeployEvents as the Galaxy is re-deployed.

The Python GalaxyRepositoryClient library wrapper is fully implemented and exercised by tests/test_galaxy.py and tests/test_galaxy_iter_hierarchy.py — use the library API (see Galaxy Repository Browse above) when calling these RPCs from Python. The four CLI subcommands above are a forward-looking parity item; see the matching .NET / Go / Rust / Java CLI implementations for the expected JSON shape when they are added.

The .NET CLI also ships bench-stream-events, which is .NET-only today and not yet present in Go / Rust / Java / Python. It will need matching coverage if the cross-language benchmark matrix grows a stream-events driver under scripts/.

Integration Checks

Run live checks only when a gateway and MXAccess-backed worker are available:

$env:MXGATEWAY_INTEGRATION = '1'
$env:MXGATEWAY_ENDPOINT = 'localhost:5000'
$env:MXGATEWAY_API_KEY = '<gateway-api-key>'
$env:MXGATEWAY_TEST_ITEM = 'Object.Attribute'
mxgw-py smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json