From 4acfa3c3742e8abb90f9a2e3b08b54a481ebb018 Mon Sep 17 00:00:00 2001 From: Jotheeswaran-Nandagopal <163156604+Jotheeswaran-Nandagopal@users.noreply.github.com> Date: Fri, 27 Sep 2024 21:23:41 +0530 Subject: [PATCH] [releases/2.1] Cherry-pick: [High Priority] Add resolve service with information API in the discovery client (#948) (#950) [High Priority] Add resolve service with information API in the discovery client (#948) * feat: add resolve service with information API to discovery client and add unit tests for it (cherry picked from commit 801220f17686e1e1b9acf27444078b2e15f69361) --- .../discovery/_client.py | 57 ++++++++++++----- .../discovery/_types.py | 14 +++++ .../measurement/info.py | 15 +++++ .../tests/unit/test_discovery_client.py | 62 +++++++++++++++++++ 4 files changed, 132 insertions(+), 16 deletions(-) diff --git a/packages/service/ni_measurement_plugin_sdk_service/discovery/_client.py b/packages/service/ni_measurement_plugin_sdk_service/discovery/_client.py index e747798e3..d504104df 100644 --- a/packages/service/ni_measurement_plugin_sdk_service/discovery/_client.py +++ b/packages/service/ni_measurement_plugin_sdk_service/discovery/_client.py @@ -2,7 +2,7 @@ import logging import threading -from typing import Optional, Sequence +from typing import Optional, Sequence, Tuple import grpc from deprecation import deprecated @@ -240,10 +240,45 @@ def resolve_service( response = self._get_stub().ResolveService(request) - return ServiceLocation( - location=response.location, - insecure_port=response.insecure_port, - ssl_authenticated_port=response.ssl_authenticated_port, + return ServiceLocation._from_grpc(response) + + def resolve_service_with_information( + self, + provided_interface: str, + service_class: str = "", + deployment_target: str = "", + version: str = "", + ) -> Tuple[ServiceLocation, ServiceInfo]: + """Resolve the location of a service along with its information. + + Given a description of a service, returns information for the service in addition to + the location of the service. If necessary, the service will be started by the discovery + service if it has not already been started. + + Args: + provided_interface: The gRPC full name of the service. + service_class: The service "class" that should be matched. If the value is not + specified and there is more than one matching service registered, an error + is returned. + deployment_target: The deployment target from which the service should be resolved. + version: The version of the service to resolve. If not specified, the latest version + will be resolved. + + Returns: + A tuple containing the service location and service information. + """ + request = discovery_service_pb2.ResolveServiceWithInformationRequest( + provided_interface=provided_interface, + service_class=service_class, + deployment_target=deployment_target, + version=version, + ) + + response = self._get_stub().ResolveServiceWithInformation(request) + + return ( + ServiceLocation._from_grpc(response.service_location), + ServiceInfo._from_grpc(response.service_descriptor), ) def enumerate_services(self, provided_interface: str) -> Sequence[ServiceInfo]: @@ -261,14 +296,4 @@ def enumerate_services(self, provided_interface: str) -> Sequence[ServiceInfo]: response = self._get_stub().EnumerateServices(request) - return [ - ServiceInfo( - service_class=service.service_class, - description_url=service.description_url, - provided_interfaces=list(service.provided_interfaces), - annotations=dict(service.annotations), - display_name=service.display_name, - versions=list(service.versions), - ) - for service in response.available_services - ] + return [ServiceInfo._from_grpc(service) for service in response.available_services] diff --git a/packages/service/ni_measurement_plugin_sdk_service/discovery/_types.py b/packages/service/ni_measurement_plugin_sdk_service/discovery/_types.py index 59b1dc09f..034e0bdd5 100644 --- a/packages/service/ni_measurement_plugin_sdk_service/discovery/_types.py +++ b/packages/service/ni_measurement_plugin_sdk_service/discovery/_types.py @@ -1,7 +1,13 @@ """Data types for the NI Discovery Service.""" +from __future__ import annotations + import typing +from ni_measurement_plugin_sdk_service._internal.stubs.ni.measurementlink.discovery.v1 import ( + discovery_service_pb2, +) + class ServiceLocation(typing.NamedTuple): """Represents the location of a service.""" @@ -19,3 +25,11 @@ def insecure_address(self) -> str: def ssl_authenticated_address(self) -> str: """Get the service's SSL-authenticated address in the format host:port.""" return f"{self.location}:{self.ssl_authenticated_port}" + + @classmethod + def _from_grpc(cls, other: discovery_service_pb2.ServiceLocation) -> ServiceLocation: + return ServiceLocation( + location=other.location, + insecure_port=other.insecure_port, + ssl_authenticated_port=other.ssl_authenticated_port, + ) diff --git a/packages/service/ni_measurement_plugin_sdk_service/measurement/info.py b/packages/service/ni_measurement_plugin_sdk_service/measurement/info.py index 1b1f5a2ac..59f6be67f 100644 --- a/packages/service/ni_measurement_plugin_sdk_service/measurement/info.py +++ b/packages/service/ni_measurement_plugin_sdk_service/measurement/info.py @@ -6,6 +6,10 @@ from pathlib import Path from typing import Dict, List, NamedTuple +from ni_measurement_plugin_sdk_service._internal.stubs.ni.measurementlink.discovery.v1 import ( + discovery_service_pb2, +) + class MeasurementInfo(NamedTuple): """A named tuple providing information about a measurement.""" @@ -65,6 +69,17 @@ class ServiceInfo(NamedTuple): """The list of versions associated with this service in the form major.minor.build[.revision] (e.g. 1.0.0).""" + @classmethod + def _from_grpc(cls, other: discovery_service_pb2.ServiceDescriptor) -> ServiceInfo: + return ServiceInfo( + service_class=other.service_class, + description_url=other.description_url, + provided_interfaces=list(other.provided_interfaces), + annotations=dict(other.annotations), + display_name=other.display_name, + versions=list(other.versions), + ) + class TypeSpecialization(enum.Enum): """Enum that represents the type specializations for measurement parameters.""" diff --git a/packages/service/tests/unit/test_discovery_client.py b/packages/service/tests/unit/test_discovery_client.py index 4e52494cb..e26a5e077 100644 --- a/packages/service/tests/unit/test_discovery_client.py +++ b/packages/service/tests/unit/test_discovery_client.py @@ -23,6 +23,8 @@ RegisterServiceRequest, RegisterServiceResponse, ResolveServiceRequest, + ResolveServiceWithInformationRequest, + ResolveServiceWithInformationResponse, ServiceDescriptor as GrpcServiceDescriptor, ServiceLocation as GrpcServiceLocation, UnregisterServiceRequest, @@ -395,6 +397,65 @@ def test___no_registered_measurements___enumerate_services___returns_empty_list( assert not available_measurements +@pytest.mark.parametrize("programming_language", ["Python", "LabVIEW"]) +def test___registered_measurements___resolve_service_with_information___sends_request( + discovery_client: DiscoveryClient, discovery_service_stub: Mock, programming_language: str +): + expected_service_info = copy.deepcopy(_TEST_SERVICE_INFO) + expected_service_info.annotations[SERVICE_PROGRAMMINGLANGUAGE_KEY] = programming_language + discovery_service_stub.ResolveServiceWithInformation.return_value = ( + ResolveServiceWithInformationResponse( + service_location=GrpcServiceLocation( + location=_TEST_SERVICE_LOCATION.location, + insecure_port=_TEST_SERVICE_LOCATION.insecure_port, + ssl_authenticated_port=_TEST_SERVICE_LOCATION.ssl_authenticated_port, + ), + service_descriptor=GrpcServiceDescriptor( + display_name=expected_service_info.display_name, + description_url=expected_service_info.description_url, + provided_interfaces=expected_service_info.provided_interfaces, + annotations=expected_service_info.annotations, + service_class=expected_service_info.service_class, + versions=expected_service_info.versions, + ), + ) + ) + + service_location, service_info = discovery_client.resolve_service_with_information( + provided_interface=_TEST_SERVICE_INFO.provided_interfaces[0], + service_class=_TEST_SERVICE_INFO.service_class, + version=_TEST_SERVICE_INFO.versions[0], + ) + + discovery_service_stub.ResolveServiceWithInformation.assert_called_once() + request: ResolveServiceWithInformationRequest = ( + discovery_service_stub.ResolveServiceWithInformation.call_args.args[0] + ) + assert _TEST_SERVICE_INFO.provided_interfaces[0] == request.provided_interface + assert _TEST_SERVICE_INFO.service_class == request.service_class + assert _TEST_SERVICE_INFO.versions[0] == request.version + _assert_service_location_equal(_TEST_SERVICE_LOCATION, service_location) + _assert_service_info_equal(expected_service_info, service_info) + + +def test___no_registered_measurements___resolve_service_with_information___raises_not_found_error( + discovery_client: DiscoveryClient, discovery_service_stub: Mock +): + discovery_service_stub.ResolveServiceWithInformation.side_effect = FakeRpcError( + grpc.StatusCode.NOT_FOUND, details="Service not found" + ) + + with pytest.raises(grpc.RpcError) as exc_info: + _ = discovery_client.resolve_service_with_information( + provided_interface=_TEST_SERVICE_INFO.provided_interfaces[0], + service_class=_TEST_SERVICE_INFO.service_class, + version=_TEST_SERVICE_INFO.versions[0], + ) + + discovery_service_stub.ResolveServiceWithInformation.assert_called_once() + assert exc_info.value.code() == grpc.StatusCode.NOT_FOUND + + @pytest.fixture(scope="module") def subprocess_popen_kwargs() -> Dict[str, Any]: kwargs: Dict[str, Any] = {} @@ -423,6 +484,7 @@ def discovery_service_stub(mocker: MockerFixture) -> Mock: stub.UnregisterService = mocker.create_autospec(grpc.UnaryUnaryMultiCallable) stub.EnumerateServices = mocker.create_autospec(grpc.UnaryUnaryMultiCallable) stub.ResolveService = mocker.create_autospec(grpc.UnaryUnaryMultiCallable) + stub.ResolveServiceWithInformation = mocker.create_autospec(grpc.UnaryUnaryMultiCallable) return stub