Issue #46: implement Python async client values errors and CLI
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
"""Tests for auth metadata and connection options."""
|
||||
|
||||
import pytest
|
||||
|
||||
from mxgateway.auth import REDACTED, ApiKey, auth_metadata, redact_secret
|
||||
from mxgateway import options as options_module
|
||||
from mxgateway.options import ClientOptions, create_channel
|
||||
|
||||
|
||||
def test_auth_metadata_adds_bearer_api_key() -> None:
|
||||
assert auth_metadata("mxgw_test_secret") == (
|
||||
("authorization", "Bearer mxgw_test_secret"),
|
||||
)
|
||||
|
||||
|
||||
def test_api_key_repr_is_redacted() -> None:
|
||||
api_key = ApiKey("mxgw_test_secret")
|
||||
|
||||
assert "mxgw_test_secret" not in repr(api_key)
|
||||
assert REDACTED in repr(api_key)
|
||||
|
||||
|
||||
def test_redact_secret_replaces_known_values() -> None:
|
||||
redacted = redact_secret(
|
||||
"authorization failed for mxgw_test_secret",
|
||||
["mxgw_test_secret"],
|
||||
)
|
||||
|
||||
assert redacted == f"authorization failed for {REDACTED}"
|
||||
|
||||
|
||||
def test_client_options_reject_plaintext_with_ca_file() -> None:
|
||||
with pytest.raises(ValueError, match="ca_file"):
|
||||
ClientOptions(
|
||||
endpoint="localhost:5000",
|
||||
plaintext=True,
|
||||
ca_file="ca.pem",
|
||||
)
|
||||
|
||||
|
||||
def test_client_options_repr_redacts_api_key() -> None:
|
||||
options = ClientOptions(endpoint="localhost:5000", api_key="mxgw_test_secret")
|
||||
|
||||
assert "mxgw_test_secret" not in repr(options)
|
||||
assert REDACTED in repr(options)
|
||||
|
||||
|
||||
def test_create_channel_uses_plaintext_channel(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[tuple[str, object]] = []
|
||||
|
||||
def fake_insecure_channel(endpoint: str, *, options: object) -> str:
|
||||
calls.append((endpoint, options))
|
||||
return "plain-channel"
|
||||
|
||||
monkeypatch.setattr(
|
||||
options_module.grpc.aio,
|
||||
"insecure_channel",
|
||||
fake_insecure_channel,
|
||||
)
|
||||
|
||||
channel = create_channel(ClientOptions(endpoint="localhost:5000", plaintext=True))
|
||||
|
||||
assert channel == "plain-channel"
|
||||
assert calls == [("localhost:5000", [])]
|
||||
|
||||
|
||||
def test_create_channel_uses_tls_channel(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[tuple[str, object, object]] = []
|
||||
|
||||
def fake_credentials(*, root_certificates: object) -> str:
|
||||
assert root_certificates is None
|
||||
return "creds"
|
||||
|
||||
def fake_secure_channel(endpoint: str, credentials: object, *, options: object) -> str:
|
||||
calls.append((endpoint, credentials, options))
|
||||
return "tls-channel"
|
||||
|
||||
monkeypatch.setattr(
|
||||
options_module.grpc,
|
||||
"ssl_channel_credentials",
|
||||
fake_credentials,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
options_module.grpc.aio,
|
||||
"secure_channel",
|
||||
fake_secure_channel,
|
||||
)
|
||||
|
||||
channel = create_channel(
|
||||
ClientOptions(
|
||||
endpoint="gateway.example:5001",
|
||||
server_name_override="gateway.test",
|
||||
),
|
||||
)
|
||||
|
||||
assert channel == "tls-channel"
|
||||
assert calls == [
|
||||
(
|
||||
"gateway.example:5001",
|
||||
"creds",
|
||||
[("grpc.ssl_target_name_override", "gateway.test")],
|
||||
),
|
||||
]
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for the Python CLI scaffold."""
|
||||
"""Tests for the Python CLI."""
|
||||
|
||||
import json
|
||||
|
||||
@@ -19,3 +19,50 @@ def test_version_json_is_deterministic() -> None:
|
||||
"package": "mxaccess-gateway-client",
|
||||
"version": __version__,
|
||||
}
|
||||
|
||||
|
||||
def test_write_parser_rejects_unknown_value_type() -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"write",
|
||||
"--session-id",
|
||||
"session-1",
|
||||
"--server-handle",
|
||||
"12",
|
||||
"--item-handle",
|
||||
"34",
|
||||
"--type",
|
||||
"unsupported",
|
||||
"--value",
|
||||
"123",
|
||||
"--api-key",
|
||||
"mxgw_test_secret",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "unsupported value type" in result.output
|
||||
|
||||
|
||||
def test_cli_error_output_redacts_api_key() -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"open-session",
|
||||
"--endpoint",
|
||||
"127.0.0.1:1",
|
||||
"--api-key",
|
||||
"mxgw_test_secret",
|
||||
"--plaintext",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "mxgw_test_secret" not in result.output
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Tests for the async client and session wrappers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from mxgateway import ClientOptions, GatewayClient, MxAccessError
|
||||
from mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_helpers_send_auth_metadata_and_preserve_raw_replies() -> None:
|
||||
stub = FakeGatewayStub()
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
session = await client.open_session(client_session_name="pytest")
|
||||
server_handle = await session.register("pytest-client")
|
||||
item_handle = await session.add_item(server_handle, "Object.Attribute")
|
||||
await session.advise(server_handle, item_handle)
|
||||
|
||||
assert session.session_id == "session-1"
|
||||
assert server_handle == 12
|
||||
assert item_handle == 34
|
||||
assert stub.open_session.metadata == (("authorization", "Bearer mxgw_test_secret"),)
|
||||
assert stub.invoke.requests[0].command.register.client_name == "pytest-client"
|
||||
assert stub.invoke.requests[1].command.add_item.item_definition == "Object.Attribute"
|
||||
assert stub.invoke.requests[2].command.advise.item_handle == 34
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mxaccess_error_preserves_raw_reply() -> None:
|
||||
stub = FakeGatewayStub()
|
||||
failure_reply = pb.MxCommandReply(
|
||||
session_id="session-1",
|
||||
kind=pb.MX_COMMAND_KIND_WRITE,
|
||||
protocol_status=pb.ProtocolStatus(
|
||||
code=pb.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE,
|
||||
message="MXAccess rejected write.",
|
||||
),
|
||||
hresult=-1,
|
||||
)
|
||||
stub.invoke.replies = [failure_reply]
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
session = await client.open_session()
|
||||
|
||||
with pytest.raises(MxAccessError) as captured:
|
||||
await session.write(12, 34, 123)
|
||||
|
||||
assert captured.value.raw_reply is failure_reply
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_events_cancels_underlying_call_when_closed() -> None:
|
||||
stream = FakeStream(
|
||||
[
|
||||
pb.MxEvent(
|
||||
session_id="session-1",
|
||||
worker_sequence=1,
|
||||
family=pb.MX_EVENT_FAMILY_ON_DATA_CHANGE,
|
||||
),
|
||||
],
|
||||
)
|
||||
stub = FakeGatewayStub(stream=stream)
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
session = await client.open_session()
|
||||
|
||||
events = session.stream_events()
|
||||
first = await anext(events)
|
||||
await events.aclose()
|
||||
|
||||
assert first.worker_sequence == 1
|
||||
assert stream.cancelled
|
||||
assert stub.stream_metadata == (("authorization", "Bearer mxgw_test_secret"),)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unary_task_cancellation_reaches_fake_call() -> None:
|
||||
blocking = BlockingCancellableUnary()
|
||||
stub = FakeGatewayStub()
|
||||
stub.OpenSession = blocking
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
task = asyncio.create_task(client.open_session())
|
||||
await blocking.started.wait()
|
||||
task.cancel()
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
assert blocking.call is not None
|
||||
assert blocking.call.cancelled
|
||||
|
||||
|
||||
class FakeGatewayStub:
|
||||
def __init__(self, stream: "FakeStream | None" = None) -> None:
|
||||
self.open_session = FakeUnary(
|
||||
[
|
||||
pb.OpenSessionReply(
|
||||
session_id="session-1",
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
),
|
||||
],
|
||||
)
|
||||
self.close_session = FakeUnary(
|
||||
[
|
||||
pb.CloseSessionReply(
|
||||
session_id="session-1",
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
),
|
||||
],
|
||||
)
|
||||
self.invoke = FakeUnary(
|
||||
[
|
||||
pb.MxCommandReply(
|
||||
session_id="session-1",
|
||||
kind=pb.MX_COMMAND_KIND_REGISTER,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
register=pb.RegisterReply(server_handle=12),
|
||||
),
|
||||
pb.MxCommandReply(
|
||||
session_id="session-1",
|
||||
kind=pb.MX_COMMAND_KIND_ADD_ITEM,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
add_item=pb.AddItemReply(item_handle=34),
|
||||
),
|
||||
pb.MxCommandReply(
|
||||
session_id="session-1",
|
||||
kind=pb.MX_COMMAND_KIND_ADVISE,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
),
|
||||
],
|
||||
)
|
||||
self.OpenSession = self.open_session
|
||||
self.CloseSession = self.close_session
|
||||
self.Invoke = self.invoke
|
||||
self._stream = stream or FakeStream([])
|
||||
self.stream_metadata: tuple[tuple[str, str], ...] | None = None
|
||||
|
||||
def StreamEvents(
|
||||
self,
|
||||
request: pb.StreamEventsRequest,
|
||||
*,
|
||||
metadata: tuple[tuple[str, str], ...],
|
||||
) -> "FakeStream":
|
||||
self.stream_request = request
|
||||
self.stream_metadata = metadata
|
||||
return self._stream
|
||||
|
||||
|
||||
class FakeUnary:
|
||||
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 BlockingCancellableUnary:
|
||||
def __init__(self) -> None:
|
||||
self.started = asyncio.Event()
|
||||
self.call: BlockingCall | None = None
|
||||
|
||||
def __call__(self, *_args: Any, **_kwargs: Any) -> "BlockingCall":
|
||||
self.call = BlockingCall(self.started)
|
||||
return self.call
|
||||
|
||||
|
||||
class BlockingCall:
|
||||
def __init__(self, started: asyncio.Event) -> None:
|
||||
self.started = started
|
||||
self.cancelled = False
|
||||
|
||||
def __await__(self):
|
||||
return self._wait().__await__()
|
||||
|
||||
async def _wait(self) -> Any:
|
||||
self.started.set()
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.cancelled = True
|
||||
|
||||
|
||||
class FakeStream:
|
||||
def __init__(self, events: list[pb.MxEvent]) -> None:
|
||||
self._events = events
|
||||
self.cancelled = False
|
||||
|
||||
def __aiter__(self) -> "FakeStream":
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> pb.MxEvent:
|
||||
if not self._events:
|
||||
await asyncio.sleep(3600)
|
||||
return self._events.pop(0)
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.cancelled = True
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Tests for typed command error mapping."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from google.protobuf.json_format import ParseDict
|
||||
|
||||
from mxgateway.errors import ensure_mxaccess_success, ensure_protocol_success
|
||||
from mxgateway import MxAccessError, MxGatewaySessionError
|
||||
from mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||
|
||||
FIXTURE_ROOT = Path(__file__).resolve().parents[2] / "proto" / "fixtures" / "behavior"
|
||||
|
||||
|
||||
def test_register_fixture_is_protocol_and_mxaccess_success() -> None:
|
||||
reply = _load_reply("command-replies/register.ok.reply.json")
|
||||
|
||||
assert ensure_protocol_success("register", reply.protocol_status, reply) is reply
|
||||
assert ensure_mxaccess_success("register", reply) is reply
|
||||
|
||||
|
||||
def test_write_failure_fixture_preserves_raw_reply() -> None:
|
||||
reply = _load_reply("command-replies/write.mxaccess-failure.reply.json")
|
||||
|
||||
assert ensure_protocol_success("write", reply.protocol_status, reply) is reply
|
||||
with pytest.raises(MxAccessError) as captured:
|
||||
ensure_mxaccess_success("write", reply)
|
||||
|
||||
assert captured.value.raw_reply is reply
|
||||
assert captured.value.raw_reply.hresult == -2147220992
|
||||
assert len(captured.value.raw_reply.statuses) == 2
|
||||
|
||||
|
||||
def test_session_status_maps_to_session_error() -> None:
|
||||
status = pb.ProtocolStatus(
|
||||
code=pb.PROTOCOL_STATUS_CODE_SESSION_NOT_FOUND,
|
||||
message="session missing",
|
||||
)
|
||||
|
||||
with pytest.raises(MxGatewaySessionError) as captured:
|
||||
ensure_protocol_success("invoke", status)
|
||||
|
||||
assert captured.value.protocol_status is status
|
||||
|
||||
|
||||
def _load_reply(name: str) -> pb.MxCommandReply:
|
||||
payload = json.loads((FIXTURE_ROOT / name).read_text(encoding="utf-8"))
|
||||
return ParseDict(payload, pb.MxCommandReply())
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Tests for MXAccess value conversion helpers."""
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from google.protobuf.json_format import ParseDict
|
||||
|
||||
from mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||
from mxgateway.values import from_mx_value, to_mx_value
|
||||
|
||||
FIXTURE_ROOT = Path(__file__).resolve().parents[2] / "proto" / "fixtures" / "behavior"
|
||||
|
||||
|
||||
def test_value_conversion_fixtures_project_expected_oneof_kind() -> None:
|
||||
payload = json.loads(
|
||||
(FIXTURE_ROOT / "values" / "value-conversion-cases.json").read_text(
|
||||
encoding="utf-8",
|
||||
),
|
||||
)
|
||||
|
||||
for case in payload["cases"]:
|
||||
value = ParseDict(case["value"], pb.MxValue())
|
||||
projection = from_mx_value(value)
|
||||
|
||||
assert projection.kind == _snake_case(case["expectedKind"])
|
||||
assert projection.raw is value
|
||||
|
||||
|
||||
def test_to_mx_value_supports_scalar_and_array_inputs() -> None:
|
||||
assert to_mx_value(True).WhichOneof("kind") == "bool_value"
|
||||
assert to_mx_value(12).int32_value == 12
|
||||
assert to_mx_value(2**40).int64_value == 2**40
|
||||
assert to_mx_value(12.5).double_value == 12.5
|
||||
assert to_mx_value("abc").string_value == "abc"
|
||||
assert to_mx_value([1, 2]).array_value.int32_values.values == [1, 2]
|
||||
assert to_mx_value(["a", "b"]).array_value.string_values.values == ["a", "b"]
|
||||
|
||||
|
||||
def test_to_mx_value_uses_utc_timestamps() -> None:
|
||||
value = to_mx_value(datetime(2026, 1, 1, 0, 0, 4, tzinfo=timezone.utc))
|
||||
|
||||
assert value.data_type == pb.MX_DATA_TYPE_TIME
|
||||
assert value.timestamp_value.seconds == 1767225604
|
||||
|
||||
|
||||
def _snake_case(value: str) -> str:
|
||||
return re.sub(r"(?<!^)(?=[A-Z])", "_", value).lower()
|
||||
Reference in New Issue
Block a user