Add Galaxy repository API and clients

This commit is contained in:
Joseph Doherty
2026-04-29 07:27:00 -04:00
parent 047d875fe6
commit 133c83029b
103 changed files with 22788 additions and 39 deletions
+70
View File
@@ -98,6 +98,76 @@ MXAccess commands and preserve raw replies on typed command exceptions.
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:
```python
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.
### 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:
```python
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:
+2 -1
View File
@@ -19,4 +19,5 @@ Get-ChildItem -Path (Join-Path $outputRoot '*_pb2_grpc.py') -File | Remove-Item
"--python_out=$outputRoot" `
"--grpc_python_out=$outputRoot" `
mxaccess_gateway.proto `
mxaccess_worker.proto
mxaccess_worker.proto `
galaxy_repository.proto
+12
View File
@@ -2,6 +2,13 @@
from .auth import ApiKey, auth_metadata
from .client import GatewayClient
from .galaxy import GalaxyRepositoryClient
from .generated.galaxy_repository_pb2 import (
DeployEvent,
GalaxyAttribute,
GalaxyObject,
WatchDeployEventsRequest,
)
from .errors import (
MxAccessError,
MxGatewayAuthenticationError,
@@ -20,6 +27,10 @@ from .version import __version__
__all__ = [
"ApiKey",
"ClientOptions",
"DeployEvent",
"GalaxyAttribute",
"GalaxyObject",
"GalaxyRepositoryClient",
"GatewayClient",
"MxAccessError",
"MxGatewayAuthenticationError",
@@ -31,6 +42,7 @@ __all__ = [
"MxGatewayWorkerError",
"MxValueView",
"Session",
"WatchDeployEventsRequest",
"__version__",
"auth_metadata",
"from_mx_value",
+199
View File
@@ -0,0 +1,199 @@
"""Async Galaxy Repository client wrapper.
Wraps the read-only ``GalaxyRepository`` gRPC service exposed by the
MxAccess Gateway. The service lets callers test connectivity to the AVEVA
System Platform Galaxy Repository (ZB SQL database), read the last
deployment timestamp, and enumerate the deployed object hierarchy plus the
attributes on each object.
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Sequence
from datetime import datetime
from typing import Any
import grpc
from google.protobuf.timestamp_pb2 import Timestamp
from .auth import merge_metadata
from .errors import map_rpc_error
from .generated import galaxy_repository_pb2 as galaxy_pb
from .generated import galaxy_repository_pb2_grpc as galaxy_pb_grpc
from .options import ClientOptions, create_channel
class GalaxyRepositoryClient:
"""Async client for the Galaxy Repository gRPC service."""
def __init__(
self,
*,
options: ClientOptions,
stub: Any,
channel: grpc.aio.Channel | None = None,
) -> None:
self.options = options
self.raw_stub = stub
self._channel = channel
self._closed = False
@classmethod
async def connect(
cls,
options: ClientOptions | None = None,
*,
endpoint: str | None = None,
api_key: str | None = None,
plaintext: bool = False,
ca_file: str | None = None,
server_name_override: str | None = None,
stub: Any | None = None,
) -> "GalaxyRepositoryClient":
"""Create a client with either a real async channel or a supplied fake stub."""
resolved = options or ClientOptions(
endpoint=endpoint or "",
api_key=api_key,
plaintext=plaintext,
ca_file=ca_file,
server_name_override=server_name_override,
)
if stub is not None:
return cls(options=resolved, stub=stub)
channel = create_channel(resolved)
return cls(
options=resolved,
stub=galaxy_pb_grpc.GalaxyRepositoryStub(channel),
channel=channel,
)
async def __aenter__(self) -> "GalaxyRepositoryClient":
return self
async def __aexit__(self, *_exc_info: object) -> None:
await self.close()
async def close(self) -> None:
"""Close the owned gRPC channel."""
if self._closed:
return
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."""
reply = await self._unary(
"test connection",
self.raw_stub.TestConnection,
galaxy_pb.TestConnectionRequest(),
)
return bool(reply.ok)
async def get_last_deploy_time(self) -> datetime | None:
"""Return the last Galaxy deploy timestamp or ``None`` when unset."""
reply = await self._unary(
"get last deploy time",
self.raw_stub.GetLastDeployTime,
galaxy_pb.GetLastDeployTimeRequest(),
)
if not reply.present:
return None
return reply.time_of_last_deploy.ToDatetime()
async def discover_hierarchy(self) -> list[galaxy_pb.GalaxyObject]:
"""Return the deployed Galaxy object hierarchy as raw proto messages."""
reply = await self._unary(
"discover hierarchy",
self.raw_stub.DiscoverHierarchy,
galaxy_pb.DiscoverHierarchyRequest(),
)
return list(reply.objects)
def watch_deploy_events(
self,
last_seen_deploy_time: datetime | None = None,
*,
metadata: Sequence[tuple[str, str]] | None = None,
) -> AsyncIterator[galaxy_pb.DeployEvent]:
"""Stream Galaxy deploy events.
On subscribe the gateway emits the current cached state and then one
event per new deploy time. ``sequence`` is monotonic per server start;
gaps mean events were dropped from the per-subscriber buffer. When
``last_seen_deploy_time`` is supplied and matches the current cached
deploy time the bootstrap event is suppressed.
"""
request = galaxy_pb.WatchDeployEventsRequest()
if last_seen_deploy_time is not None:
timestamp = Timestamp()
timestamp.FromDatetime(last_seen_deploy_time)
request.last_seen_deploy_time.CopyFrom(timestamp)
kwargs: dict[str, Any] = {"metadata": merge_metadata(self.options.api_key, metadata)}
if self.options.stream_timeout is not None:
kwargs["timeout"] = self.options.stream_timeout
try:
call = self.raw_stub.WatchDeployEvents(request, **kwargs)
except TypeError as error:
if "timeout" not in kwargs or "unexpected keyword argument 'timeout'" not in str(error):
raise
kwargs.pop("timeout")
call = self.raw_stub.WatchDeployEvents(request, **kwargs)
return _canceling_iterator(call)
async def _unary(
self,
operation: str,
method: Any,
request: Any,
*,
metadata: Sequence[tuple[str, str]] | None = None,
) -> Any:
kwargs: dict[str, Any] = {"metadata": merge_metadata(self.options.api_key, metadata)}
if self.options.call_timeout is not None:
kwargs["timeout"] = self.options.call_timeout
try:
call = method(request, **kwargs)
except TypeError as error:
if "timeout" not in kwargs or "unexpected keyword argument 'timeout'" not in str(error):
raise
kwargs.pop("timeout")
call = method(request, **kwargs)
try:
return await call
except asyncio.CancelledError:
cancel = getattr(call, "cancel", None)
if cancel is not None:
cancel()
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()
@@ -21,7 +21,15 @@ sys.modules.setdefault("mxaccess_worker_pb2", mxaccess_worker_pb2)
mxaccess_worker_pb2_grpc = import_module(f"{__name__}.mxaccess_worker_pb2_grpc")
sys.modules.setdefault("mxaccess_worker_pb2_grpc", mxaccess_worker_pb2_grpc)
galaxy_repository_pb2 = import_module(f"{__name__}.galaxy_repository_pb2")
sys.modules.setdefault("galaxy_repository_pb2", galaxy_repository_pb2)
galaxy_repository_pb2_grpc = import_module(f"{__name__}.galaxy_repository_pb2_grpc")
sys.modules.setdefault("galaxy_repository_pb2_grpc", galaxy_repository_pb2_grpc)
__all__ = [
"galaxy_repository_pb2",
"galaxy_repository_pb2_grpc",
"mxaccess_gateway_pb2",
"mxaccess_gateway_pb2_grpc",
"mxaccess_worker_pb2",
@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: galaxy_repository.proto
# Protobuf Python Version: 6.31.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
6,
31,
1,
'',
'galaxy_repository.proto'
)
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17galaxy_repository.proto\x12\x14galaxy_repository.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x17\n\x15TestConnectionRequest\"!\n\x13TestConnectionReply\x12\n\n\x02ok\x18\x01 \x01(\x08\"\x1a\n\x18GetLastDeployTimeRequest\"b\n\x16GetLastDeployTimeReply\x12\x0f\n\x07present\x18\x01 \x01(\x08\x12\x37\n\x13time_of_last_deploy\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x1a\n\x18\x44iscoverHierarchyRequest\"M\n\x16\x44iscoverHierarchyReply\x12\x33\n\x07objects\x18\x01 \x03(\x0b\x32\".galaxy_repository.v1.GalaxyObject\"U\n\x18WatchDeployEventsRequest\x12\x39\n\x15last_seen_deploy_time\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xdd\x01\n\x0b\x44\x65ployEvent\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12/\n\x0bobserved_at\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x37\n\x13time_of_last_deploy\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12#\n\x1btime_of_last_deploy_present\x18\x04 \x01(\x08\x12\x14\n\x0cobject_count\x18\x05 \x01(\x05\x12\x17\n\x0f\x61ttribute_count\x18\x06 \x01(\x05\"\x93\x02\n\x0cGalaxyObject\x12\x12\n\ngobject_id\x18\x01 \x01(\x05\x12\x10\n\x08tag_name\x18\x02 \x01(\t\x12\x16\n\x0e\x63ontained_name\x18\x03 \x01(\t\x12\x13\n\x0b\x62rowse_name\x18\x04 \x01(\t\x12\x19\n\x11parent_gobject_id\x18\x05 \x01(\x05\x12\x0f\n\x07is_area\x18\x06 \x01(\x08\x12\x13\n\x0b\x63\x61tegory_id\x18\x07 \x01(\x05\x12\x1c\n\x14hosted_by_gobject_id\x18\x08 \x01(\x05\x12\x16\n\x0etemplate_chain\x18\t \x03(\t\x12\x39\n\nattributes\x18\n \x03(\x0b\x32%.galaxy_repository.v1.GalaxyAttribute\"\xa8\x02\n\x0fGalaxyAttribute\x12\x16\n\x0e\x61ttribute_name\x18\x01 \x01(\t\x12\x1a\n\x12\x66ull_tag_reference\x18\x02 \x01(\t\x12\x14\n\x0cmx_data_type\x18\x03 \x01(\x05\x12\x16\n\x0e\x64\x61ta_type_name\x18\x04 \x01(\t\x12\x10\n\x08is_array\x18\x05 \x01(\x08\x12\x17\n\x0f\x61rray_dimension\x18\x06 \x01(\x05\x12\x1f\n\x17\x61rray_dimension_present\x18\x07 \x01(\x08\x12\x1d\n\x15mx_attribute_category\x18\x08 \x01(\x05\x12\x1f\n\x17security_classification\x18\t \x01(\x05\x12\x15\n\ris_historized\x18\n \x01(\x08\x12\x10\n\x08is_alarm\x18\x0b \x01(\x08\x32\xcc\x03\n\x10GalaxyRepository\x12h\n\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n\x11\x44iscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01\x42#\xaa\x02 MxGateway.Contracts.Proto.Galaxyb\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'galaxy_repository_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
_globals['DESCRIPTOR']._loaded_options = None
_globals['DESCRIPTOR']._serialized_options = b'\252\002 MxGateway.Contracts.Proto.Galaxy'
_globals['_TESTCONNECTIONREQUEST']._serialized_start=82
_globals['_TESTCONNECTIONREQUEST']._serialized_end=105
_globals['_TESTCONNECTIONREPLY']._serialized_start=107
_globals['_TESTCONNECTIONREPLY']._serialized_end=140
_globals['_GETLASTDEPLOYTIMEREQUEST']._serialized_start=142
_globals['_GETLASTDEPLOYTIMEREQUEST']._serialized_end=168
_globals['_GETLASTDEPLOYTIMEREPLY']._serialized_start=170
_globals['_GETLASTDEPLOYTIMEREPLY']._serialized_end=268
_globals['_DISCOVERHIERARCHYREQUEST']._serialized_start=270
_globals['_DISCOVERHIERARCHYREQUEST']._serialized_end=296
_globals['_DISCOVERHIERARCHYREPLY']._serialized_start=298
_globals['_DISCOVERHIERARCHYREPLY']._serialized_end=375
_globals['_WATCHDEPLOYEVENTSREQUEST']._serialized_start=377
_globals['_WATCHDEPLOYEVENTSREQUEST']._serialized_end=462
_globals['_DEPLOYEVENT']._serialized_start=465
_globals['_DEPLOYEVENT']._serialized_end=686
_globals['_GALAXYOBJECT']._serialized_start=689
_globals['_GALAXYOBJECT']._serialized_end=964
_globals['_GALAXYATTRIBUTE']._serialized_start=967
_globals['_GALAXYATTRIBUTE']._serialized_end=1263
_globals['_GALAXYREPOSITORY']._serialized_start=1266
_globals['_GALAXYREPOSITORY']._serialized_end=1726
# @@protoc_insertion_point(module_scope)
@@ -0,0 +1,244 @@
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
"""Client and server classes corresponding to protobuf-defined services."""
import grpc
import warnings
import galaxy_repository_pb2 as galaxy__repository__pb2
GRPC_GENERATED_VERSION = '1.80.0'
GRPC_VERSION = grpc.__version__
_version_not_supported = False
try:
from grpc._utilities import first_version_is_lower
_version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
except ImportError:
_version_not_supported = True
if _version_not_supported:
raise RuntimeError(
f'The grpc package installed is at version {GRPC_VERSION},'
+ ' but the generated code in galaxy_repository_pb2_grpc.py depends on'
+ f' grpcio>={GRPC_GENERATED_VERSION}.'
+ f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
+ f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
)
class GalaxyRepositoryStub(object):
"""Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
database). Lets clients enumerate the deployed object hierarchy and each
object's dynamic attributes so they know what tag references to subscribe
to via the MxAccessGateway service.
"""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.TestConnection = channel.unary_unary(
'/galaxy_repository.v1.GalaxyRepository/TestConnection',
request_serializer=galaxy__repository__pb2.TestConnectionRequest.SerializeToString,
response_deserializer=galaxy__repository__pb2.TestConnectionReply.FromString,
_registered_method=True)
self.GetLastDeployTime = channel.unary_unary(
'/galaxy_repository.v1.GalaxyRepository/GetLastDeployTime',
request_serializer=galaxy__repository__pb2.GetLastDeployTimeRequest.SerializeToString,
response_deserializer=galaxy__repository__pb2.GetLastDeployTimeReply.FromString,
_registered_method=True)
self.DiscoverHierarchy = channel.unary_unary(
'/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy',
request_serializer=galaxy__repository__pb2.DiscoverHierarchyRequest.SerializeToString,
response_deserializer=galaxy__repository__pb2.DiscoverHierarchyReply.FromString,
_registered_method=True)
self.WatchDeployEvents = channel.unary_stream(
'/galaxy_repository.v1.GalaxyRepository/WatchDeployEvents',
request_serializer=galaxy__repository__pb2.WatchDeployEventsRequest.SerializeToString,
response_deserializer=galaxy__repository__pb2.DeployEvent.FromString,
_registered_method=True)
class GalaxyRepositoryServicer(object):
"""Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
database). Lets clients enumerate the deployed object hierarchy and each
object's dynamic attributes so they know what tag references to subscribe
to via the MxAccessGateway service.
"""
def TestConnection(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def GetLastDeployTime(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def DiscoverHierarchy(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def WatchDeployEvents(self, request, context):
"""Server-stream of deploy events. The server emits the current state immediately
on subscribe (so clients can bootstrap their cache without waiting for the next
deploy), then emits one event each time the gateway's hierarchy cache observes
a new galaxy.time_of_last_deploy. The sequence field is monotonically
increasing per server start; gaps indicate the per-subscriber buffer dropped
older events because the client was too slow.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_GalaxyRepositoryServicer_to_server(servicer, server):
rpc_method_handlers = {
'TestConnection': grpc.unary_unary_rpc_method_handler(
servicer.TestConnection,
request_deserializer=galaxy__repository__pb2.TestConnectionRequest.FromString,
response_serializer=galaxy__repository__pb2.TestConnectionReply.SerializeToString,
),
'GetLastDeployTime': grpc.unary_unary_rpc_method_handler(
servicer.GetLastDeployTime,
request_deserializer=galaxy__repository__pb2.GetLastDeployTimeRequest.FromString,
response_serializer=galaxy__repository__pb2.GetLastDeployTimeReply.SerializeToString,
),
'DiscoverHierarchy': grpc.unary_unary_rpc_method_handler(
servicer.DiscoverHierarchy,
request_deserializer=galaxy__repository__pb2.DiscoverHierarchyRequest.FromString,
response_serializer=galaxy__repository__pb2.DiscoverHierarchyReply.SerializeToString,
),
'WatchDeployEvents': grpc.unary_stream_rpc_method_handler(
servicer.WatchDeployEvents,
request_deserializer=galaxy__repository__pb2.WatchDeployEventsRequest.FromString,
response_serializer=galaxy__repository__pb2.DeployEvent.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'galaxy_repository.v1.GalaxyRepository', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
server.add_registered_method_handlers('galaxy_repository.v1.GalaxyRepository', rpc_method_handlers)
# This class is part of an EXPERIMENTAL API.
class GalaxyRepository(object):
"""Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
database). Lets clients enumerate the deployed object hierarchy and each
object's dynamic attributes so they know what tag references to subscribe
to via the MxAccessGateway service.
"""
@staticmethod
def TestConnection(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/galaxy_repository.v1.GalaxyRepository/TestConnection',
galaxy__repository__pb2.TestConnectionRequest.SerializeToString,
galaxy__repository__pb2.TestConnectionReply.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def GetLastDeployTime(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/galaxy_repository.v1.GalaxyRepository/GetLastDeployTime',
galaxy__repository__pb2.GetLastDeployTimeRequest.SerializeToString,
galaxy__repository__pb2.GetLastDeployTimeReply.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def DiscoverHierarchy(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy',
galaxy__repository__pb2.DiscoverHierarchyRequest.SerializeToString,
galaxy__repository__pb2.DiscoverHierarchyReply.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def WatchDeployEvents(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_stream(
request,
target,
'/galaxy_repository.v1.GalaxyRepository/WatchDeployEvents',
galaxy__repository__pb2.WatchDeployEventsRequest.SerializeToString,
galaxy__repository__pb2.DeployEvent.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
+320
View File
@@ -0,0 +1,320 @@
"""Tests for the Galaxy Repository async client wrapper."""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
from typing import Any
import pytest
from google.protobuf.timestamp_pb2 import Timestamp
from mxgateway import ClientOptions, DeployEvent, GalaxyRepositoryClient, WatchDeployEventsRequest
from mxgateway.generated import galaxy_repository_pb2 as galaxy_pb
from mxgateway.generated import galaxy_repository_pb2_grpc as galaxy_pb_grpc
def test_galaxy_messages_import() -> None:
request = galaxy_pb.DiscoverHierarchyRequest()
obj = galaxy_pb.GalaxyObject(
gobject_id=42,
tag_name="DelmiaReceiver_001",
contained_name="DelmiaReceiver",
browse_name="DelmiaReceiver",
parent_gobject_id=10,
is_area=False,
category_id=4,
hosted_by_gobject_id=10,
template_chain=["$ApplicationObject", "$DelmiaReceiver"],
attributes=[
galaxy_pb.GalaxyAttribute(
attribute_name="DownloadPath",
full_tag_reference="DelmiaReceiver_001.DownloadPath",
mx_data_type=8,
data_type_name="String",
),
],
)
assert request.DESCRIPTOR is not None
assert obj.attributes[0].attribute_name == "DownloadPath"
assert hasattr(galaxy_pb_grpc, "GalaxyRepositoryStub")
@pytest.mark.asyncio
async def test_test_connection_returns_bool_and_sends_auth() -> None:
stub = FakeGalaxyStub()
stub.test_connection.replies = [galaxy_pb.TestConnectionReply(ok=True)]
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
stub=stub,
)
result = await client.test_connection()
assert result is True
assert stub.test_connection.metadata == (("authorization", "Bearer mxgw_test_secret"),)
assert isinstance(stub.test_connection.requests[0], galaxy_pb.TestConnectionRequest)
@pytest.mark.asyncio
async def test_get_last_deploy_time_returns_datetime_when_present() -> None:
timestamp = Timestamp()
timestamp.FromDatetime(datetime(2025, 4, 1, 12, 30, 45, tzinfo=timezone.utc))
stub = FakeGalaxyStub()
stub.get_last_deploy_time.replies = [
galaxy_pb.GetLastDeployTimeReply(present=True, time_of_last_deploy=timestamp),
]
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
when = await client.get_last_deploy_time()
assert when is not None
assert when.year == 2025
assert when.month == 4
assert when.day == 1
assert when.hour == 12
assert when.minute == 30
assert when.second == 45
@pytest.mark.asyncio
async def test_get_last_deploy_time_returns_none_when_not_present() -> None:
stub = FakeGalaxyStub()
stub.get_last_deploy_time.replies = [galaxy_pb.GetLastDeployTimeReply(present=False)]
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
assert await client.get_last_deploy_time() is None
@pytest.mark.asyncio
async def test_discover_hierarchy_returns_proto_objects() -> None:
stub = FakeGalaxyStub()
stub.discover_hierarchy.replies = [
galaxy_pb.DiscoverHierarchyReply(
objects=[
galaxy_pb.GalaxyObject(
gobject_id=1,
tag_name="TestMachine_001",
contained_name="TestMachine",
browse_name="TestMachine_001",
is_area=True,
),
galaxy_pb.GalaxyObject(
gobject_id=2,
tag_name="DelmiaReceiver_001",
contained_name="DelmiaReceiver",
browse_name="DelmiaReceiver",
parent_gobject_id=1,
attributes=[
galaxy_pb.GalaxyAttribute(
attribute_name="DownloadPath",
full_tag_reference="DelmiaReceiver_001.DownloadPath",
mx_data_type=8,
data_type_name="String",
),
],
),
],
),
]
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
objects = await client.discover_hierarchy()
assert isinstance(objects, list)
assert len(objects) == 2
assert objects[0].tag_name == "TestMachine_001"
assert objects[1].attributes[0].full_tag_reference == "DelmiaReceiver_001.DownloadPath"
@pytest.mark.asyncio
async def test_watch_deploy_events_yields_events_in_order() -> None:
ts1 = Timestamp()
ts1.FromDatetime(datetime(2025, 4, 1, 10, 0, 0, tzinfo=timezone.utc))
ts2 = Timestamp()
ts2.FromDatetime(datetime(2025, 4, 1, 11, 0, 0, tzinfo=timezone.utc))
events = [
galaxy_pb.DeployEvent(
sequence=1,
observed_at=ts1,
time_of_last_deploy=ts1,
time_of_last_deploy_present=True,
object_count=10,
attribute_count=42,
),
galaxy_pb.DeployEvent(
sequence=2,
observed_at=ts2,
time_of_last_deploy=ts2,
time_of_last_deploy_present=True,
object_count=11,
attribute_count=45,
),
]
stub = FakeGalaxyStub()
stub.watch_deploy_events.replies = list(events)
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
stub=stub,
)
received: list[DeployEvent] = []
async for event in client.watch_deploy_events():
received.append(event)
assert len(received) == 2
assert received[0].sequence == 1
assert received[1].sequence == 2
assert received[0].object_count == 10
assert received[1].attribute_count == 45
assert stub.watch_deploy_events.metadata == (("authorization", "Bearer mxgw_test_secret"),)
assert isinstance(stub.watch_deploy_events.requests[0], galaxy_pb.WatchDeployEventsRequest)
# No last_seen_deploy_time was passed, so the request should leave it unset.
assert not stub.watch_deploy_events.requests[0].HasField("last_seen_deploy_time")
@pytest.mark.asyncio
async def test_watch_deploy_events_propagates_last_seen_deploy_time() -> None:
last_seen = datetime(2025, 4, 1, 12, 0, 0, tzinfo=timezone.utc)
stub = FakeGalaxyStub()
stub.watch_deploy_events.replies = []
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
async for _ in client.watch_deploy_events(last_seen_deploy_time=last_seen):
pass
request = stub.watch_deploy_events.requests[0]
assert isinstance(request, WatchDeployEventsRequest)
assert request.HasField("last_seen_deploy_time")
assert request.last_seen_deploy_time.ToDatetime(tzinfo=timezone.utc) == last_seen
@pytest.mark.asyncio
async def test_watch_deploy_events_cancellation_closes_stream() -> None:
ts = Timestamp()
ts.FromDatetime(datetime(2025, 4, 1, 10, 0, 0, tzinfo=timezone.utc))
stub = FakeGalaxyStub()
# Use a "blocking" stream that never yields more after the first event.
stub.watch_deploy_events = FakeStream(
[galaxy_pb.DeployEvent(sequence=1, observed_at=ts)],
block_after_replies=True,
)
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
iterator = client.watch_deploy_events()
first = await iterator.__anext__()
assert first.sequence == 1
# Break the iterator by aclose() — this should drive the cancel path.
await iterator.aclose()
assert stub.watch_deploy_events.cancel_called is True
@pytest.mark.asyncio
async def test_close_marks_channel_closed_when_no_real_channel() -> None:
stub = FakeGalaxyStub()
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
await client.close()
# Idempotent: a second close should not raise.
await client.close()
class FakeGalaxyStub:
def __init__(self) -> None:
self.test_connection = FakeUnary([galaxy_pb.TestConnectionReply(ok=False)])
self.get_last_deploy_time = FakeUnary([galaxy_pb.GetLastDeployTimeReply(present=False)])
self.discover_hierarchy = FakeUnary([galaxy_pb.DiscoverHierarchyReply()])
self.watch_deploy_events = FakeStream([])
self.TestConnection = self.test_connection
self.GetLastDeployTime = self.get_last_deploy_time
self.DiscoverHierarchy = self.discover_hierarchy
@property
def WatchDeployEvents(self) -> "FakeStream": # noqa: N802 — gRPC naming
return self.watch_deploy_events
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], ...],
timeout: float | None = None,
) -> Any:
self.requests.append(request)
self.metadata = metadata
return self.replies.pop(0)
class FakeStream:
"""Sync-callable fake matching the gRPC unary-stream surface.
Calling the stub returns ``self`` (an async iterator). After exhausting the
seeded ``replies``, iteration either ends (default) or blocks indefinitely
(``block_after_replies=True``) so cancellation paths can be exercised.
"""
def __init__(
self,
replies: list[Any],
*,
block_after_replies: bool = False,
) -> None:
self.replies = list(replies)
self.requests: list[Any] = []
self.metadata: tuple[tuple[str, str], ...] | None = None
self.cancel_called = False
self._block_after_replies = block_after_replies
def __call__(
self,
request: Any,
*,
metadata: tuple[tuple[str, str], ...],
timeout: float | None = None,
) -> "FakeStream":
self.requests.append(request)
self.metadata = metadata
return self
def __aiter__(self) -> "FakeStream":
return self
async def __anext__(self) -> Any:
if self.replies:
return self.replies.pop(0)
if self._block_after_replies:
# Sleep forever until the consumer cancels us.
await asyncio.Event().wait()
raise StopAsyncIteration
def cancel(self) -> None:
self.cancel_called = True