diff --git a/packages/generator/ni_measurement_plugin_sdk_generator/client/templates/measurement_plugin_client.py.mako b/packages/generator/ni_measurement_plugin_sdk_generator/client/templates/measurement_plugin_client.py.mako index cbb06bda4..51319c317 100644 --- a/packages/generator/ni_measurement_plugin_sdk_generator/client/templates/measurement_plugin_client.py.mako +++ b/packages/generator/ni_measurement_plugin_sdk_generator/client/templates/measurement_plugin_client.py.mako @@ -3,19 +3,20 @@ """Generated client API for the ${display_name | repr} measurement plug-in.""" import logging -import pathlib import threading % if len(enum_by_class_name): from enum import Enum % endif -% for module in built_in_import_modules: -${module} -% endfor -from typing import Any, Generator, Iterable, List, NamedTuple, Optional +from pathlib import Path +<% + typing_imports = ["Any", "Generator", "List", "Optional"] + if output_metadata: + typing_imports += ["Iterable", "NamedTuple"] +%>\ +from typing import ${", ".join(sorted(typing_imports))} import grpc -from google.protobuf import any_pb2 -from google.protobuf import descriptor_pool +from google.protobuf import any_pb2, descriptor_pool from ni_measurement_plugin_sdk_service._internal.stubs.ni.measurementlink.measurement.v2 import ( measurement_service_pb2 as v2_measurement_service_pb2, measurement_service_pb2_grpc as v2_measurement_service_pb2_grpc, @@ -27,7 +28,9 @@ from ni_measurement_plugin_sdk_service.discovery import DiscoveryClient from ni_measurement_plugin_sdk_service.grpc.channelpool import GrpcChannelPool from ni_measurement_plugin_sdk_service.measurement.client_support import ( create_file_descriptor, +% if output_metadata: deserialize_parameters, +% endif ParameterMetadata, serialize_parameters, ) @@ -268,7 +271,7 @@ class ${class_name}: else: return False - def register_pin_map(self, pin_map_path: pathlib.Path) -> None: + def register_pin_map(self, pin_map_path: Path) -> None: """Registers the pin map with the pin map service. Args: diff --git a/packages/generator/tests/acceptance/test_client_generator.py b/packages/generator/tests/acceptance/test_client_generator.py index 51c628862..569f86ff9 100644 --- a/packages/generator/tests/acceptance/test_client_generator.py +++ b/packages/generator/tests/acceptance/test_client_generator.py @@ -9,14 +9,18 @@ from tests.conftest import CliRunnerFunction from tests.utilities.discovery_service_process import DiscoveryServiceProcess -from tests.utilities.measurements import non_streaming_data_measurement, streaming_data_measurement +from tests.utilities.measurements import ( + non_streaming_data_measurement, + streaming_data_measurement, + void_measurement, +) -def test___command_line_args___create_client___render_without_error( +def test___non_streaming_measurement___create_client___render_without_error( create_client: CliRunnerFunction, test_assets_directory: pathlib.Path, tmp_path_factory: pytest.TempPathFactory, - measurement_service: MeasurementService, + non_streaming_measurement_service: MeasurementService, ) -> None: temp_directory = tmp_path_factory.mktemp("measurement_plugin_client_files") module_name = "non_streaming_data_measurement_client" @@ -43,7 +47,38 @@ def test___command_line_args___create_client___render_without_error( ) -def test___command_line_args___create_client_for_all_registered_measurements___renders_without_error( +def test___void_measurement___create_client___render_without_error( + create_client: CliRunnerFunction, + test_assets_directory: pathlib.Path, + tmp_path_factory: pytest.TempPathFactory, + void_measurement_service: MeasurementService, +) -> None: + temp_directory = tmp_path_factory.mktemp("measurement_plugin_client_files") + module_name = "void_measurement_client" + golden_path = test_assets_directory / "example_renders" / "measurement_plugin_client" + filename = f"{module_name}.py" + + result = create_client( + [ + "--measurement-service-class", + "ni.tests.VoidMeasurement_Python", + "--module-name", + module_name, + "--class-name", + "VoidMeasurementClient", + "--directory-out", + str(temp_directory), + ] + ) + + assert result.exit_code == 0 + _assert_equal( + golden_path / filename, + temp_directory / filename, + ) + + +def test___all_registered_measurements___create_client___renders_without_error( create_client: CliRunnerFunction, tmp_path_factory: pytest.TempPathFactory, multiple_measurement_service: MeasurementService, @@ -72,11 +107,11 @@ def test___command_line_args___create_client_for_all_registered_measurements___r ) -def test___command_line_args_with_registered_measurements___create_client_using_interactive_mode___renders_without_error( +def test___interactive_mode_with_registered_measurements___create_client___renders_without_error( create_client: CliRunnerFunction, test_assets_directory: pathlib.Path, tmp_path_factory: pytest.TempPathFactory, - measurement_service: MeasurementService, + non_streaming_measurement_service: MeasurementService, ) -> None: temp_directory = tmp_path_factory.mktemp("measurement_plugin_client_files") golden_path = test_assets_directory / "example_renders" / "measurement_plugin_client" @@ -98,7 +133,7 @@ def test___command_line_args_with_registered_measurements___create_client_using_ ) -def test___command_line_args_without_registering_any_measurement___create_client_using_interactive_mode___raises_exception( +def test___interactive_mode_without_registered_measurements___create_client___raises_exception( create_client: CliRunnerFunction, ) -> None: result = create_client(["--interactive"]) @@ -106,10 +141,10 @@ def test___command_line_args_without_registering_any_measurement___create_client assert "No registered measurements." in str(result.exception) -def test___command_line_args___create_client___render_with_proper_line_ending( +def test___non_streaming_measurement___create_client___render_with_proper_line_ending( create_client: CliRunnerFunction, tmp_path_factory: pytest.TempPathFactory, - measurement_service: MeasurementService, + non_streaming_measurement_service: MeasurementService, ) -> None: temp_directory = tmp_path_factory.mktemp("measurement_plugin_client_files") module_name = "non_streaming_data_measurement_client" @@ -154,14 +189,23 @@ def _assert_line_ending(file_path: pathlib.Path) -> None: @pytest.fixture -def measurement_service( +def non_streaming_measurement_service( discovery_service_process: DiscoveryServiceProcess, ) -> Generator[MeasurementService, None, None]: - """Test fixture that creates and hosts a Measurement Plug-In Service.""" + """Test fixture that creates and hosts a non streaming Measurement Plug-In Service.""" with non_streaming_data_measurement.measurement_service.host_service() as service: yield service +@pytest.fixture +def void_measurement_service( + discovery_service_process: DiscoveryServiceProcess, +) -> Generator[MeasurementService, None, None]: + """Test fixture that creates and hosts a void Measurement Plug-In Service.""" + with void_measurement.measurement_service.host_service() as service: + yield service + + @pytest.fixture def multiple_measurement_service( discovery_service_process: DiscoveryServiceProcess, diff --git a/packages/generator/tests/test_assets/example_renders/measurement_plugin_client/non_streaming_data_measurement_client.py b/packages/generator/tests/test_assets/example_renders/measurement_plugin_client/non_streaming_data_measurement_client.py index e9fcdd0dd..c78aed13c 100644 --- a/packages/generator/tests/test_assets/example_renders/measurement_plugin_client/non_streaming_data_measurement_client.py +++ b/packages/generator/tests/test_assets/example_renders/measurement_plugin_client/non_streaming_data_measurement_client.py @@ -1,15 +1,13 @@ """Generated client API for the 'Non-Streaming Data Measurement (Py)' measurement plug-in.""" import logging -import pathlib import threading from enum import Enum from pathlib import Path from typing import Any, Generator, Iterable, List, NamedTuple, Optional import grpc -from google.protobuf import any_pb2 -from google.protobuf import descriptor_pool +from google.protobuf import any_pb2, descriptor_pool from ni_measurement_plugin_sdk_service._internal.stubs.ni.measurementlink.measurement.v2 import ( measurement_service_pb2 as v2_measurement_service_pb2, measurement_service_pb2_grpc as v2_measurement_service_pb2_grpc, @@ -622,7 +620,7 @@ def cancel(self) -> bool: else: return False - def register_pin_map(self, pin_map_path: pathlib.Path) -> None: + def register_pin_map(self, pin_map_path: Path) -> None: """Registers the pin map with the pin map service. Args: diff --git a/packages/generator/tests/test_assets/example_renders/measurement_plugin_client/void_measurement_client.py b/packages/generator/tests/test_assets/example_renders/measurement_plugin_client/void_measurement_client.py new file mode 100644 index 000000000..4f467b169 --- /dev/null +++ b/packages/generator/tests/test_assets/example_renders/measurement_plugin_client/void_measurement_client.py @@ -0,0 +1,222 @@ +"""Generated client API for the 'Void Measurement (Py)' measurement plug-in.""" + +import logging +import threading +from pathlib import Path +from typing import Any, Generator, List, Optional + +import grpc +from google.protobuf import any_pb2, descriptor_pool +from ni_measurement_plugin_sdk_service._internal.stubs.ni.measurementlink.measurement.v2 import ( + measurement_service_pb2 as v2_measurement_service_pb2, + measurement_service_pb2_grpc as v2_measurement_service_pb2_grpc, +) +from ni_measurement_plugin_sdk_service.discovery import DiscoveryClient +from ni_measurement_plugin_sdk_service.grpc.channelpool import GrpcChannelPool +from ni_measurement_plugin_sdk_service.measurement.client_support import ( + create_file_descriptor, + ParameterMetadata, + serialize_parameters, +) +from ni_measurement_plugin_sdk_service.pin_map import PinMapClient +from ni_measurement_plugin_sdk_service.session_management import PinMapContext + +_logger = logging.getLogger(__name__) + +_V2_MEASUREMENT_SERVICE_INTERFACE = "ni.measurementlink.measurement.v2.MeasurementService" + + +class VoidMeasurementClient: + """Client for the 'Void Measurement (Py)' measurement plug-in.""" + + def __init__( + self, + *, + discovery_client: Optional[DiscoveryClient] = None, + pin_map_client: Optional[PinMapClient] = None, + grpc_channel: Optional[grpc.Channel] = None, + grpc_channel_pool: Optional[GrpcChannelPool] = None, + ): + """Initialize the Measurement Plug-In Client. + + Args: + discovery_client: An optional discovery client. + + pin_map_client: An optional pin map client. + + grpc_channel: An optional gRPC channel targeting a measurement service. + + grpc_channel_pool: An optional gRPC channel pool. + """ + self._initialization_lock = threading.RLock() + self._service_class = "ni.tests.VoidMeasurement_Python" + self._grpc_channel_pool = grpc_channel_pool + self._discovery_client = discovery_client + self._pin_map_client = pin_map_client + self._stub: Optional[v2_measurement_service_pb2_grpc.MeasurementServiceStub] = None + self._measure_response: Optional[ + Generator[v2_measurement_service_pb2.MeasureResponse, None, None] + ] = None + self._configuration_metadata = { + 1: ParameterMetadata( + display_name="Integer In", + type=5, + repeated=False, + default_value=10, + annotations={}, + message_type="", + field_name="Integer_In", + enum_type=None, + ) + } + self._output_metadata = {} + if grpc_channel is not None: + self._stub = v2_measurement_service_pb2_grpc.MeasurementServiceStub(grpc_channel) + self._create_file_descriptor() + self._pin_map_context: Optional[PinMapContext] = None + + @property + def pin_map_context(self) -> PinMapContext: + """Get the pin map context for the measurement.""" + return self._pin_map_context + + @pin_map_context.setter + def pin_map_context(self, val: PinMapContext) -> None: + if not isinstance(val, PinMapContext): + raise TypeError( + f"Invalid type {type(val)}: The given value must be an instance of PinMapContext." + ) + self._pin_map_context = val + + @property + def sites(self) -> List[int]: + """The sites where the measurement must be executed.""" + if self._pin_map_context is not None: + return self._pin_map_context.sites + + @sites.setter + def sites(self, val: List[int]) -> None: + if self._pin_map_context is None: + raise AttributeError( + "Cannot set sites because the pin map context is None. Please provide a pin map context or register a pin map before setting sites." + ) + self._pin_map_context = self._pin_map_context._replace(sites=val) + + def _get_stub(self) -> v2_measurement_service_pb2_grpc.MeasurementServiceStub: + if self._stub is None: + with self._initialization_lock: + if self._stub is None: + service_location = self._get_discovery_client().resolve_service( + provided_interface=_V2_MEASUREMENT_SERVICE_INTERFACE, + service_class=self._service_class, + ) + channel = self._get_grpc_channel_pool().get_channel( + service_location.insecure_address + ) + self._stub = v2_measurement_service_pb2_grpc.MeasurementServiceStub(channel) + return self._stub + + def _get_discovery_client(self) -> DiscoveryClient: + if self._discovery_client is None: + with self._initialization_lock: + if self._discovery_client is None: + self._discovery_client = DiscoveryClient( + grpc_channel_pool=self._get_grpc_channel_pool(), + ) + return self._discovery_client + + def _get_grpc_channel_pool(self) -> GrpcChannelPool: + if self._grpc_channel_pool is None: + with self._initialization_lock: + if self._grpc_channel_pool is None: + self._grpc_channel_pool = GrpcChannelPool() + return self._grpc_channel_pool + + def _get_pin_map_client(self) -> PinMapClient: + if self._pin_map_client is None: + with self._initialization_lock: + if self._pin_map_client is None: + self._pin_map_client = PinMapClient( + discovery_client=self._get_discovery_client(), + grpc_channel_pool=self._get_grpc_channel_pool(), + ) + return self._pin_map_client + + def _create_file_descriptor(self) -> None: + create_file_descriptor( + input_metadata=list(self._configuration_metadata.values()), + output_metadata=list(self._output_metadata.values()), + service_name=self._service_class, + pool=descriptor_pool.Default(), + ) + + def _create_measure_request( + self, parameter_values: List[Any] + ) -> v2_measurement_service_pb2.MeasureRequest: + serialized_configuration = any_pb2.Any( + type_url="type.googleapis.com/ni.measurementlink.measurement.v2.MeasurementConfigurations", + value=serialize_parameters( + parameter_metadata_dict=self._configuration_metadata, + parameter_values=parameter_values, + service_name=f"{self._service_class}.Configurations", + ), + ) + return v2_measurement_service_pb2.MeasureRequest( + configuration_parameters=serialized_configuration, + pin_map_context=self._pin_map_context._to_grpc() if self._pin_map_context else None, + ) + + def measure(self, integer_in: int = 10) -> None: + """Perform a single measurement. + + Returns: + Measurement outputs. + """ + stream_measure_response = self.stream_measure(integer_in) + for response in stream_measure_response: + pass + + def stream_measure(self, integer_in: int = 10) -> Generator[None, None, None]: + """Perform a streaming measurement. + + Returns: + Stream of measurement outputs. + """ + parameter_values = [integer_in] + with self._initialization_lock: + if self._measure_response is not None: + raise RuntimeError( + "A measurement is currently in progress. To make concurrent measurement requests, please create a new client instance." + ) + request = self._create_measure_request(parameter_values) + self._measure_response = self._get_stub().Measure(request) + try: + for response in self._measure_response: + yield + except grpc.RpcError as e: + if e.code() == grpc.StatusCode.CANCELLED: + _logger.debug("The measurement is canceled.") + raise + finally: + with self._initialization_lock: + self._measure_response = None + + def cancel(self) -> bool: + """Cancels the active measurement call.""" + with self._initialization_lock: + if self._measure_response: + return self._measure_response.cancel() + else: + return False + + def register_pin_map(self, pin_map_path: Path) -> None: + """Registers the pin map with the pin map service. + + Args: + pin_map_path: Absolute path of the pin map file. + """ + pin_map_id = self._get_pin_map_client().update_pin_map(pin_map_path) + if self._pin_map_context is None: + self._pin_map_context = PinMapContext(pin_map_id=pin_map_id, sites=[0]) + else: + self._pin_map_context = self._pin_map_context._replace(pin_map_id=pin_map_id) diff --git a/packages/generator/tests/utilities/measurements/void_measurement/VoidMeasurement.serviceconfig b/packages/generator/tests/utilities/measurements/void_measurement/VoidMeasurement.serviceconfig new file mode 100644 index 000000000..aa9f6d38c --- /dev/null +++ b/packages/generator/tests/utilities/measurements/void_measurement/VoidMeasurement.serviceconfig @@ -0,0 +1,19 @@ +{ + "services": [ + { + "displayName": "Void Measurement (Py)", + "serviceClass": "ni.tests.VoidMeasurement_Python", + "descriptionUrl": "", + "providedInterfaces": [ + "ni.measurementlink.measurement.v1.MeasurementService", + "ni.measurementlink.measurement.v2.MeasurementService" + ], + "path": "start.bat", + "annotations": { + "ni/service.description": "Measurement plug-in test service that performs a measurement without output.", + "ni/service.collection": "NI.Tests", + "ni/service.tags": [] + } + } + ] +} diff --git a/packages/generator/tests/utilities/measurements/void_measurement/__init__.py b/packages/generator/tests/utilities/measurements/void_measurement/__init__.py new file mode 100644 index 000000000..8366f6b88 --- /dev/null +++ b/packages/generator/tests/utilities/measurements/void_measurement/__init__.py @@ -0,0 +1,44 @@ +"""Contains utility functions to test void measurement service. """ + +import threading +import time +from pathlib import Path +from typing import List, Tuple + +import grpc +import ni_measurement_plugin_sdk_service as nims + +service_directory = Path(__file__).resolve().parent +measurement_service = nims.MeasurementService( + service_config_path=service_directory / "VoidMeasurement.serviceconfig", + version="0.1.0.0", + ui_file_paths=[ + service_directory, + ], +) + + +@measurement_service.register_measurement +@measurement_service.configuration("Integer In", nims.DataType.Int32, 10) +def measure( + integer_input: int, +) -> Tuple[()]: + """Perform a measurement with no output.""" + cancellation_event = threading.Event() + measurement_service.context.add_cancel_callback(cancellation_event.set) + + response_interval_in_seconds = 1 + data: List[int] = [] + + for index in range(0, integer_input): + update_time = time.monotonic() + data.append(index) + + # Delay for the remaining portion of the requested interval and check for cancellation. + delay = max(0.0, response_interval_in_seconds - (time.monotonic() - update_time)) + if cancellation_event.wait(delay): + measurement_service.context.abort( + grpc.StatusCode.CANCELLED, "Client requested cancellation." + ) + + return ()