Resolve Client.Python-001/002/004/006/007/008/010/011/012 findings

Client.Python-001: dropped "scaffold" from the stale pyproject description.
Client.Python-002 (re-triaged): stale finding — MxGatewayCommandError is
already exported and in __all__; no change needed.
Client.Python-004: removed the dead `closed` variable in _smoke; the CLI
smoke now uses `async with session`.
Client.Python-006: close() on both clients and Session had an unlocked
check-then-set race; `_closed` is now set before the await.
Client.Python-007: gateway stream iterators now share one helper that
explicitly catches CancelledError and cancels the call.
Client.Python-008: to_mx_value now rejects nan/inf; float/bytes mapping
documented.
Client.Python-010: removed the circular-import-workaround late imports in
favour of TYPE_CHECKING / module-scope imports.
Client.Python-011: ensure_mxaccess_success no longer treats a proto3-default
success==0 with an unset category as a failure.
Client.Python-012 (Won't Fix): invoke_raw deliberately skips MXAccess-failure
detection for parity tests; documented the contract instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-18 22:59:24 -04:00
parent b4f5e8eb48
commit a7bf1ef95d
10 changed files with 385 additions and 79 deletions
+37 -21
View File
@@ -72,14 +72,20 @@ class GatewayClient:
await self.close()
async def close(self) -> None:
"""Close the owned gRPC channel."""
"""Close the owned gRPC channel.
Idempotent, including under concurrent calls: ``_closed`` is set
before the ``await`` so a second coroutine entering ``close()``
while the first is still awaiting the channel close returns
immediately instead of issuing a second ``channel.close()``.
"""
if self._closed:
return
self._closed = True
if self._channel is not None:
await self._channel.close()
self._closed = True
async def open_session(
self,
@@ -117,7 +123,15 @@ class GatewayClient:
return reply
async def invoke_raw(self, request: pb.MxCommandRequest) -> pb.MxCommandReply:
"""Send an `Invoke` RPC and return the raw reply."""
"""Send an `Invoke` RPC and return the raw reply.
Enforces gateway protocol success only. MXAccess HRESULT/status
failures are left embedded in the reply and do not raise
`MxAccessError` — parity-test callers must inspect the reply's
`protocol_status`, `hresult`, and `statuses` themselves. Use
`Session.invoke` for the variant that also raises on MXAccess
failure.
"""
reply = await self._unary("invoke", self.raw_stub.Invoke, request)
ensure_protocol_success("invoke", reply.protocol_status, reply)
return reply
@@ -134,7 +148,7 @@ class GatewayClient:
if self.options.stream_timeout is not None:
kwargs["timeout"] = self.options.stream_timeout
call = _open_stream(self.raw_stub.StreamEvents, request, kwargs)
return _canceling_iterator(call)
return _canceling_iterator(call, "stream events")
async def acknowledge_alarm(
self,
@@ -170,7 +184,7 @@ class GatewayClient:
if self.options.stream_timeout is not None:
kwargs["timeout"] = self.options.stream_timeout
call = _open_stream(self.raw_stub.QueryActiveAlarms, request, kwargs)
return _canceling_active_alarms_iterator(call)
return _canceling_iterator(call, "query active alarms")
async def _unary(
self,
@@ -218,24 +232,26 @@ def _open_stream(method: Any, request: Any, kwargs: dict[str, Any]) -> Any:
return method(request, **kwargs)
async def _canceling_iterator(call: Any) -> AsyncIterator[pb.MxEvent]:
async def _canceling_iterator(call: Any, operation: str) -> AsyncIterator[Any]:
"""Yield from a server-streaming call and cancel it when iteration stops.
Explicitly catches :class:`asyncio.CancelledError` to cancel the
underlying call before re-raising, then repeats the cancel in the
``finally`` block so the call is also cancelled on a clean break or an
``aclose()``. ``galaxy._canceling_iterator`` delegates here so the
gateway and Galaxy stream helpers stay identical.
"""
try:
async for event in call:
yield event
async for item in call:
yield item
except asyncio.CancelledError:
cancel = getattr(call, "cancel", None)
if cancel is not None:
cancel()
raise
except grpc.RpcError as error:
raise map_rpc_error("stream events", error) from error
finally:
cancel = getattr(call, "cancel", None)
if cancel is not None:
cancel()
async def _canceling_active_alarms_iterator(call: Any) -> AsyncIterator[pb.ActiveAlarmSnapshot]:
try:
async for snapshot in call:
yield snapshot
except grpc.RpcError as error:
raise map_rpc_error("query active alarms", error) from error
raise map_rpc_error(operation, error) from error
finally:
cancel = getattr(call, "cancel", None)
if cancel is not None:
+23 -1
View File
@@ -138,7 +138,7 @@ def ensure_mxaccess_success(operation: str, reply: pb.MxCommandReply) -> pb.MxCo
)
for mx_status in reply.statuses:
if mx_status.success == 0:
if _is_mxaccess_status_failure(mx_status):
raise MxAccessError(
_mxaccess_message(operation, reply),
protocol_status=status,
@@ -148,6 +148,28 @@ def ensure_mxaccess_success(operation: str, reply: pb.MxCommandReply) -> pb.MxCo
return reply
def _is_mxaccess_status_failure(mx_status: pb.MxStatusProxy) -> bool:
"""Return ``True`` only for a populated MXAccess status reporting failure.
MXAccess uses ``success == 0`` as the failure flag, but ``0`` is also the
proto3 scalar default. The gateway emits placeholder ``MxStatusProxy``
entries with ``success`` unset for null ``MXSTATUS_PROXY`` COM entries
(see ``MxStatusProxyConverter.ConvertMany``); such an entry has
``category`` of ``UNSPECIFIED`` or ``UNKNOWN``. Treating it as a failure
would raise ``MxAccessError`` for a reply that carries no real failure,
so failure is keyed on ``success == 0`` together with a populated,
non-OK status category.
"""
if mx_status.success != 0:
return False
return mx_status.category not in (
pb.MX_STATUS_CATEGORY_UNSPECIFIED,
pb.MX_STATUS_CATEGORY_UNKNOWN,
pb.MX_STATUS_CATEGORY_OK,
)
def _mxaccess_message(operation: str, reply: pb.MxCommandReply) -> str:
status_text = reply.protocol_status.message or "MXAccess command failed"
hresult = reply.hresult if reply.HasField("hresult") else None
+10 -20
View File
@@ -18,6 +18,7 @@ import grpc
from google.protobuf.timestamp_pb2 import Timestamp
from .auth import merge_metadata
from .client import _canceling_iterator
from .errors import MxGatewayError, map_rpc_error
from .generated import galaxy_repository_pb2 as galaxy_pb
from .generated import galaxy_repository_pb2_grpc as galaxy_pb_grpc
@@ -83,14 +84,20 @@ class GalaxyRepositoryClient:
await self.close()
async def close(self) -> None:
"""Close the owned gRPC channel."""
"""Close the owned gRPC channel.
Idempotent, including under concurrent calls: ``_closed`` is set
before the ``await`` so a second coroutine entering ``close()``
while the first is still awaiting the channel close returns
immediately instead of issuing a second ``channel.close()``.
"""
if self._closed:
return
self._closed = True
if self._channel is not None:
await self._channel.close()
self._closed = True
async def test_connection(self) -> bool:
"""Return ``True`` when the gateway can reach the Galaxy Repository DB."""
@@ -189,7 +196,7 @@ class GalaxyRepositoryClient:
kwargs.pop("timeout")
call = self.raw_stub.WatchDeployEvents(request, **kwargs)
return _canceling_iterator(call)
return _canceling_iterator(call, "watch deploy events")
async def _unary(
self,
@@ -218,20 +225,3 @@ class GalaxyRepositoryClient:
raise
except grpc.RpcError as error:
raise map_rpc_error(operation, error) from error
async def _canceling_iterator(call: Any) -> AsyncIterator[galaxy_pb.DeployEvent]:
try:
async for event in call:
yield event
except asyncio.CancelledError:
cancel = getattr(call, "cancel", None)
if cancel is not None:
cancel()
raise
except grpc.RpcError as error:
raise map_rpc_error("watch deploy events", error) from error
finally:
cancel = getattr(call, "cancel", None)
if cancel is not None:
cancel()
+22 -8
View File
@@ -3,11 +3,15 @@
from __future__ import annotations
from collections.abc import AsyncIterator, Sequence
from typing import TYPE_CHECKING
from .errors import ensure_mxaccess_success
from .generated import mxaccess_gateway_pb2 as pb
from .values import MxValueInput, to_mx_value
if TYPE_CHECKING:
from .client import GatewayClient
MAX_BULK_ITEMS = 1000
@@ -36,7 +40,13 @@ class Session:
await self.close()
async def close(self, *, client_correlation_id: str = "") -> pb.CloseSessionReply:
"""Close the gateway session. Repeated calls return a local closed reply."""
"""Close the gateway session. Repeated calls return a local closed reply.
Idempotent, including under concurrent calls: ``_closed`` is set
before the ``CloseSession`` RPC is awaited so a second coroutine
entering ``close()`` while the first RPC is in flight returns the
local closed reply instead of issuing a second ``CloseSession``.
"""
if self._closed:
return pb.CloseSessionReply(
@@ -44,15 +54,14 @@ class Session:
final_state=pb.SESSION_STATE_CLOSED,
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
)
self._closed = True
reply = await self.client.close_session_raw(
return await self.client.close_session_raw(
pb.CloseSessionRequest(
session_id=self.session_id,
client_correlation_id=client_correlation_id,
),
)
self._closed = True
return reply
async def invoke(self, command: pb.MxCommand, *, correlation_id: str = "") -> pb.MxCommandReply:
"""Invoke a raw command and enforce gateway and MXAccess success."""
@@ -66,7 +75,15 @@ class Session:
*,
correlation_id: str = "",
) -> pb.MxCommandReply:
"""Invoke a raw command and preserve the raw reply."""
"""Invoke a raw command and preserve the raw reply.
Enforces gateway protocol success only — unlike :meth:`invoke`, it
does not run MXAccess-failure detection. An MXAccess HRESULT or
``MxStatusProxy`` status failure is left embedded in the returned
reply and no ``MxAccessError`` is raised. Parity-test callers must
inspect ``protocol_status``, ``hresult``, and ``statuses`` on the
reply themselves.
"""
return await self.client.invoke_raw(
pb.MxCommandRequest(
@@ -399,6 +416,3 @@ class Session:
def _ensure_bulk_size(name: str, count: int) -> None:
if count > MAX_BULK_ITEMS:
raise ValueError(f"{name} bulk commands are limited to {MAX_BULK_ITEMS} item(s)")
from .client import GatewayClient # noqa: E402
+26 -1
View File
@@ -1,7 +1,20 @@
"""MXAccess value conversion helpers."""
"""MXAccess value conversion helpers.
Value-mapping assumptions (see ``to_mx_value``):
* A Python ``float`` maps to ``VT_R8`` / ``MX_DATA_TYPE_DOUBLE``. Only finite
values are accepted — ``nan``, ``inf`` and ``-inf`` raise ``ValueError``
rather than being forwarded to MXAccess, which has no defined wire
representation for non-finite doubles.
* A Python ``bytes`` value maps to ``VT_RECORD`` / ``MX_DATA_TYPE_UNKNOWN``
and is carried in ``raw_value``. This is an opaque pass-through: MXAccess
does not interpret the bytes. Pass ``data_type`` explicitly when a concrete
MXAccess type is required.
"""
from __future__ import annotations
import math
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
@@ -60,6 +73,7 @@ def to_mx_value(value: MxValueInput, *, data_type: str | None = None) -> pb.MxVa
)
if isinstance(value, float):
_ensure_finite(value)
return pb.MxValue(
data_type=_data_type(data_type, pb.MX_DATA_TYPE_DOUBLE),
variant_type="VT_R8",
@@ -177,6 +191,8 @@ def _sequence_to_mx_value(
return pb.MxValue(data_type=pb.MX_DATA_TYPE_INTEGER, array_value=array)
if all(isinstance(item, float) for item in sequence):
for item in sequence:
_ensure_finite(item)
array = pb.MxArray(
element_data_type=pb.MX_DATA_TYPE_DOUBLE,
variant_type="VT_ARRAY|VT_R8",
@@ -232,3 +248,12 @@ def _data_type(name: str | None, default: int) -> int:
if name is None:
return default
return pb.MxDataType.Value(name)
def _ensure_finite(value: float) -> None:
"""Reject non-finite doubles, which MXAccess cannot represent on the wire."""
if not math.isfinite(value):
raise ValueError(
f"MxValue double inputs must be finite; got {value!r}",
)
+3 -8
View File
@@ -18,6 +18,7 @@ from mxgateway.client import GatewayClient
from mxgateway.errors import MxGatewayError
from mxgateway.generated import mxaccess_gateway_pb2 as pb
from mxgateway.options import ClientOptions
from mxgateway.session import Session
from mxgateway.values import MxValueInput
MAX_AGGREGATE_EVENTS = 10_000
@@ -383,8 +384,7 @@ async def _write2(**kwargs: Any) -> dict[str, Any]:
async def _smoke(**kwargs: Any) -> dict[str, Any]:
async with await _connect(kwargs) as client:
session = await client.open_session(client_session_name=kwargs["client_name"])
closed = False
try:
async with session:
server_handle = await session.register(kwargs["client_name"])
item_handle = await session.add_item(server_handle, kwargs["item"])
await session.advise(server_handle, item_handle)
@@ -399,9 +399,6 @@ async def _smoke(**kwargs: Any) -> dict[str, Any]:
"itemHandle": item_handle,
"events": [_message_dict(event) for event in events],
}
finally:
if not closed:
await session.close()
async def _connect(kwargs: dict[str, Any]) -> GatewayClient:
@@ -419,9 +416,7 @@ async def _connect(kwargs: dict[str, Any]) -> GatewayClient:
)
def _session(client: GatewayClient, session_id: str):
from mxgateway.session import Session
def _session(client: GatewayClient, session_id: str) -> Session:
return Session(client=client, session_id=session_id)