diff --git a/ni_measurementlink_generator/ni_measurementlink_generator/template.py b/ni_measurementlink_generator/ni_measurementlink_generator/template.py index 64d86e3c5..341e469ee 100644 --- a/ni_measurementlink_generator/ni_measurementlink_generator/template.py +++ b/ni_measurementlink_generator/ni_measurementlink_generator/template.py @@ -2,7 +2,7 @@ import logging import pathlib import re -from typing import Optional +from typing import Optional, Tuple import click from mako import exceptions @@ -93,6 +93,12 @@ def _resolve_service_class(service_class: str, display_name: str) -> str: "--service-class", help="Service Class that the measurement belongs to. Default: '_Python'", ) +@click.option( + "-D", + "--description", + default="", + help="Short description of the measurement", +) @click.option( "-d", "--description-url", @@ -104,6 +110,22 @@ def _resolve_service_class(service_class: str, display_name: str) -> str: "--directory-out", help="Output directory for measurement files. Default: '/'", ) +@click.option( + "-c", + "--collection", + default="", + help="\b\nThe collection that this measurement belongs to. Collection names are specified" + "using a period-delimited namespace hierarchy and are case-insensitive." + "\nExample: 'CurrentTests.Inrush'", +) +@click.option( + "-t", + "--tags", + default=[], + multiple=True, + help="\b\nTags describing the measurement. This option may be repeated to specify multiple tags. Tags are case-insensitive." + "\nExample: '-t test -t Internal'", +) @click.option( "-v", "--verbose", @@ -117,6 +139,9 @@ def create_measurement( service_class: str, description_url: str, directory_out: Optional[str], + description: str, + collection: str, + tags: Tuple[str, ...], verbose: bool, ) -> None: """Generate a Python measurement service from a template. @@ -166,6 +191,9 @@ def create_measurement( service_class=service_class, description_url=description_url, ui_file_type=ui_file_type, + description=description, + collection=collection, + tags=list(tags), ) if ui_file_type == "MeasurementUI": _create_file( diff --git a/ni_measurementlink_generator/ni_measurementlink_generator/templates/measurement.serviceconfig.mako b/ni_measurementlink_generator/ni_measurementlink_generator/templates/measurement.serviceconfig.mako index a4ec42200..1ebc7af3a 100644 --- a/ni_measurementlink_generator/ni_measurementlink_generator/templates/measurement.serviceconfig.mako +++ b/ni_measurementlink_generator/ni_measurementlink_generator/templates/measurement.serviceconfig.mako @@ -1,20 +1,25 @@ -<%page args="display_name, service_class, description_url"/>\ +<%page args="display_name, service_class, description_url, description, collection, tags"/>\ <% import json service_config = { - "services": [ - { - "displayName": display_name, - "serviceClass": service_class, - "descriptionUrl": description_url, - "providedInterfaces": [ - "ni.measurementlink.measurement.v1.MeasurementService", - "ni.measurementlink.measurement.v2.MeasurementService", - ], - "path": "start.bat", - } - ] + "services": [ + { + "displayName": display_name, + "serviceClass": service_class, + "descriptionUrl": description_url, + "providedInterfaces": [ + "ni.measurementlink.measurement.v1.MeasurementService", + "ni.measurementlink.measurement.v2.MeasurementService", + ], + "path": "start.bat", + "annotations": { + "ni/service.description": description, + "ni/service.collection": collection, + "ni/service.tags": tags + }, + } + ] } %>\ ${json.dumps(service_config, indent=2)} \ No newline at end of file diff --git a/ni_measurementlink_generator/tests/acceptance/test_generator.py b/ni_measurementlink_generator/tests/acceptance/test_generator.py index 16e09e05b..d8d2423b9 100644 --- a/ni_measurementlink_generator/tests/acceptance/test_generator.py +++ b/ni_measurementlink_generator/tests/acceptance/test_generator.py @@ -27,7 +27,49 @@ def test___command_line_args___create_measurement___render_without_exception( ] ) - golden_path = test_assets_directory / "example_renders" + golden_path = test_assets_directory / "example_renders" / "measurement" + + filenames = ["measurement.py", "SampleMeasurement.serviceconfig", "start.bat", "_helpers.py"] + for filename in filenames: + _assert_equal( + golden_path / filename, + temp_directory / filename, + ) + + +def test___command_line_args___create_measurement_with_annotations___render_without_exception( + test_assets_directory: pathlib.Path, tmp_path_factory: pytest.TempPathFactory +): + temp_directory = tmp_path_factory.mktemp("measurement_files") + + with pytest.raises(SystemExit): + template.create_measurement( + [ + "Sample Measurement", + "--measurement-version", + "1.2.3.4", + "--ui-file", + "MeasurementUI.measui", + "--service-class", + "SampleMeasurement_Python", + "-D", + "Measurement description", + "--description-url", + "https://www.example.com/SampleMeasurement.html", + "--directory-out", + temp_directory, + "--collection", + "Measurement.Collection", + "--tags", + "M1", + "--tags", + "M2", + "--tags", + "M3", + ] + ) + + golden_path = test_assets_directory / "example_renders" / "measurement_with_annotations" filenames = ["measurement.py", "SampleMeasurement.serviceconfig", "start.bat", "_helpers.py"] for filename in filenames: diff --git a/ni_measurementlink_generator/tests/test_assets/example_renders/SampleMeasurement.serviceconfig b/ni_measurementlink_generator/tests/test_assets/example_renders/measurement/SampleMeasurement.serviceconfig similarity index 69% rename from ni_measurementlink_generator/tests/test_assets/example_renders/SampleMeasurement.serviceconfig rename to ni_measurementlink_generator/tests/test_assets/example_renders/measurement/SampleMeasurement.serviceconfig index d5c69f906..8dcb56f09 100644 --- a/ni_measurementlink_generator/tests/test_assets/example_renders/SampleMeasurement.serviceconfig +++ b/ni_measurementlink_generator/tests/test_assets/example_renders/measurement/SampleMeasurement.serviceconfig @@ -8,7 +8,12 @@ "ni.measurementlink.measurement.v1.MeasurementService", "ni.measurementlink.measurement.v2.MeasurementService" ], - "path": "start.bat" + "path": "start.bat", + "annotations": { + "ni/service.description": "", + "ni/service.collection": "", + "ni/service.tags": [] + } } ] } \ No newline at end of file diff --git a/ni_measurementlink_generator/tests/test_assets/example_renders/_helpers.py b/ni_measurementlink_generator/tests/test_assets/example_renders/measurement/_helpers.py similarity index 100% rename from ni_measurementlink_generator/tests/test_assets/example_renders/_helpers.py rename to ni_measurementlink_generator/tests/test_assets/example_renders/measurement/_helpers.py diff --git a/ni_measurementlink_generator/tests/test_assets/example_renders/measurement.py b/ni_measurementlink_generator/tests/test_assets/example_renders/measurement/measurement.py similarity index 100% rename from ni_measurementlink_generator/tests/test_assets/example_renders/measurement.py rename to ni_measurementlink_generator/tests/test_assets/example_renders/measurement/measurement.py diff --git a/ni_measurementlink_generator/tests/test_assets/example_renders/start.bat b/ni_measurementlink_generator/tests/test_assets/example_renders/measurement/start.bat similarity index 100% rename from ni_measurementlink_generator/tests/test_assets/example_renders/start.bat rename to ni_measurementlink_generator/tests/test_assets/example_renders/measurement/start.bat diff --git a/ni_measurementlink_generator/tests/test_assets/example_renders/measurement_with_annotations/SampleMeasurement.serviceconfig b/ni_measurementlink_generator/tests/test_assets/example_renders/measurement_with_annotations/SampleMeasurement.serviceconfig new file mode 100644 index 000000000..4bc0add87 --- /dev/null +++ b/ni_measurementlink_generator/tests/test_assets/example_renders/measurement_with_annotations/SampleMeasurement.serviceconfig @@ -0,0 +1,23 @@ +{ + "services": [ + { + "displayName": "Sample Measurement", + "serviceClass": "SampleMeasurement_Python", + "descriptionUrl": "https://www.example.com/SampleMeasurement.html", + "providedInterfaces": [ + "ni.measurementlink.measurement.v1.MeasurementService", + "ni.measurementlink.measurement.v2.MeasurementService" + ], + "path": "start.bat", + "annotations": { + "ni/service.description": "Measurement description", + "ni/service.collection": "Measurement.Collection", + "ni/service.tags": [ + "M1", + "M2", + "M3" + ] + } + } + ] +} \ No newline at end of file diff --git a/ni_measurementlink_generator/tests/test_assets/example_renders/measurement_with_annotations/_helpers.py b/ni_measurementlink_generator/tests/test_assets/example_renders/measurement_with_annotations/_helpers.py new file mode 100644 index 000000000..1059789d5 --- /dev/null +++ b/ni_measurementlink_generator/tests/test_assets/example_renders/measurement_with_annotations/_helpers.py @@ -0,0 +1,301 @@ +"""Helper classes and functions for MeasurementLink examples.""" + +import logging +import pathlib +import types +from typing import ( + Any, + Callable, + List, + NamedTuple, + Optional, + Tuple, + TypeVar, + Union, +) + +import click +import grpc +import ni_measurementlink_service as nims +from ni_measurementlink_service import session_management +from ni_measurementlink_service._internal.discovery_client import DiscoveryClient +from ni_measurementlink_service._internal.stubs.ni.measurementlink.pinmap.v1 import ( + pin_map_service_pb2, + pin_map_service_pb2_grpc, +) +from ni_measurementlink_service.measurement.service import ( + GrpcChannelPool, + MeasurementService, +) + + +class ServiceOptions(NamedTuple): + """Service options specified on the command line.""" + + use_grpc_device: bool = False + grpc_device_address: str = "" + + use_simulation: bool = False + + +def get_service_options(**kwargs) -> ServiceOptions: + """Get service options from keyword arguments.""" + return ServiceOptions( + use_grpc_device=kwargs.get("use_grpc_device", False), + grpc_device_address=kwargs.get("grpc_device_address", ""), + use_simulation=kwargs.get("use_simulation", False), + ) + + +T = TypeVar("T") + + +class PinMapClient(object): + """Class that communicates with the pin map service.""" + + def __init__(self, *, grpc_channel: grpc.Channel): + """Initialize pin map client.""" + self._client: pin_map_service_pb2_grpc.PinMapServiceStub = ( + pin_map_service_pb2_grpc.PinMapServiceStub(grpc_channel) + ) + + def update_pin_map(self, pin_map_path: str) -> str: + """Update registered pin map contents. + + Create and register a pin map if a pin map resource for the specified pin map id is not + found. + + Args: + pin_map_path: The file path of the pin map to register as a pin map resource. + + Returns: + The resource id of the pin map that is registered to the pin map service. + """ + pin_map_path_obj = pathlib.Path(pin_map_path) + # By convention, the pin map id is the .pinmap file path. + request = pin_map_service_pb2.UpdatePinMapFromXmlRequest( + pin_map_id=pin_map_path, pin_map_xml=pin_map_path_obj.read_text(encoding="utf-8") + ) + response: pin_map_service_pb2.PinMap = self._client.UpdatePinMapFromXml(request) + return response.pin_map_id + + +class GrpcChannelPoolHelper(GrpcChannelPool): + """Class that manages gRPC channel lifetimes.""" + + def __init__(self): + """Initialize the GrpcChannelPool object.""" + super().__init__() + self._discovery_client = DiscoveryClient() + + @property + def pin_map_channel(self) -> grpc.Channel: + """Return gRPC channel to pin map service.""" + return self.get_channel( + self._discovery_client.resolve_service( + provided_interface="ni.measurementlink.pinmap.v1.PinMapService", + service_class="ni.measurementlink.pinmap.v1.PinMapService", + ).insecure_address + ) + + @property + def session_management_channel(self) -> grpc.Channel: + """Return gRPC channel to session management service.""" + return self.get_channel( + self._discovery_client.resolve_service( + provided_interface=session_management.GRPC_SERVICE_INTERFACE_NAME, + service_class=session_management.GRPC_SERVICE_CLASS, + ).insecure_address + ) + + def get_grpc_device_channel(self, provided_interface: str) -> grpc.Channel: + """Return gRPC channel to specified NI gRPC Device service. + + Args: + provided_interface (str): The gRPC Full Name of the service. + + """ + return self.get_channel( + self._discovery_client.resolve_service( + provided_interface=provided_interface, + service_class="ni.measurementlink.v1.grpcdeviceserver", + ).insecure_address + ) + + +class TestStandSupport(object): + """Class that communicates with TestStand.""" + + def __init__(self, sequence_context: Any) -> None: + """Initialize the TestStandSupport object. + + Args: + sequence_context: + The SequenceContext COM object from the TestStand sequence execution. + (Dynamically typed.) + """ + self._sequence_context = sequence_context + + def get_active_pin_map_id(self) -> str: + """Get the active pin map id from the NI.MeasurementLink.PinMapId temporary global variable. + + Returns: + The resource id of the pin map that is registered to the pin map service. + """ + return self._sequence_context.Engine.TemporaryGlobals.GetValString( + "NI.MeasurementLink.PinMapId", 0x0 + ) + + def set_active_pin_map_id(self, pin_map_id: str) -> None: + """Set the NI.MeasurementLink.PinMapId temporary global variable to the specified id. + + Args: + pin_map_id: + The resource id of the pin map that is registered to the pin map service. + """ + self._sequence_context.Engine.TemporaryGlobals.SetValString( + "NI.MeasurementLink.PinMapId", 0x1, pin_map_id + ) + + def resolve_file_path(self, file_path: str) -> str: + """Resolve the absolute path to a file using the TestStand search directories. + + Args: + file_path: + An absolute or relative path to the file. If this is a relative path, this function + searches the TestStand search directories for it. + + Returns: + The absolute path to the file. + """ + if pathlib.Path(file_path).is_absolute(): + return file_path + (_, absolute_path, _, _, user_canceled) = self._sequence_context.Engine.FindFileEx( + fileToFind=file_path, + absolutePath=None, + srchDirType=None, + searchDirectoryIndex=None, + userCancelled=None, # Must match spelling used by TestStand + searchContext=self._sequence_context.SequenceFile, + ) + if user_canceled: + raise RuntimeError("File lookup canceled by user.") + return absolute_path + + +def configure_logging(verbosity: int): + """Configure logging for this process.""" + if verbosity > 1: + level = logging.DEBUG + elif verbosity == 1: + level = logging.INFO + else: + level = logging.WARNING + logging.basicConfig(format="%(asctime)s %(levelname)s: %(message)s", level=level) + + +F = TypeVar("F", bound=Callable) + + +def verbosity_option(func: F) -> F: + """Decorator for --verbose command line option.""" + return click.option( + "-v", + "--verbose", + "verbosity", + count=True, + help="Enable verbose logging. Repeat to increase verbosity.", + )(func) + + +def grpc_device_options(func: F) -> F: + """Decorator for NI gRPC Device Server command line options.""" + use_grpc_device_option = click.option( + "--use-grpc-device/--no-use-grpc-device", + default=True, + is_flag=True, + help="Use the NI gRPC Device Server.", + ) + grpc_device_address_option = click.option( + "--grpc-device-address", + default="", + help="NI gRPC Device Server address (e.g. localhost:31763). If unspecified, use the discovery service to resolve the address.", + ) + return grpc_device_address_option(use_grpc_device_option(func)) + + +def use_simulation_option(default: bool) -> Callable[[F], F]: + """Decorator for --use-simulation command line option.""" + return click.option( + "--use-simulation/--no-use-simulation", + default=default, + is_flag=True, + help="Use simulated instruments.", + ) + + +def get_grpc_device_channel( + measurement_service: MeasurementService, + driver_module: types.ModuleType, + service_options: ServiceOptions, +) -> Optional[grpc.Channel]: + """Returns driver specific grpc device channel.""" + if service_options.use_grpc_device: + if service_options.grpc_device_address: + return measurement_service.channel_pool.get_channel(service_options.grpc_device_address) + + return measurement_service.get_channel( + provided_interface=getattr(driver_module, "GRPC_SERVICE_INTERFACE_NAME"), + service_class="ni.measurementlink.v1.grpcdeviceserver", + ) + return None + + +def create_session_management_client( + measurement_service: MeasurementService, +) -> nims.session_management.Client: + """Return created session management client.""" + return nims.session_management.Client( + grpc_channel=measurement_service.get_channel( + provided_interface=nims.session_management.GRPC_SERVICE_INTERFACE_NAME, + service_class=nims.session_management.GRPC_SERVICE_CLASS, + ) + ) + + +def get_session_and_channel_for_pin( + session_info: List[nims.session_management.SessionInformation], + pin: str, + site: Optional[int] = None, +) -> Tuple[int, List[str]]: + """Returns the session information based on the given pin names.""" + session_and_channel_info = get_sessions_and_channels_for_pins( + session_info=session_info, pins=[pin], site=site + ) + + if len(session_and_channel_info) != 1: + raise ValueError(f"Unsupported number of sessions for {pin}: {len(session_info)}") + return session_and_channel_info[0] + + +def get_sessions_and_channels_for_pins( + session_info: List[nims.session_management.SessionInformation], + pins: Union[str, List[str]], + site: Optional[int] = None, +) -> List[Tuple[int, List[str]]]: + """Returns the session information based on the given pin names.""" + pin_names = [pins] if isinstance(pins, str) else pins + session_and_channel_info = [] + for session_index, session_details in enumerate(session_info): + channel_list = [ + mapping.channel + for mapping in session_details.channel_mappings + if mapping.pin_or_relay_name in pin_names and (site is None or mapping.site == site) + ] + if len(channel_list) != 0: + session_and_channel_info.append((session_index, channel_list)) + + if len(session_and_channel_info) == 0: + raise KeyError(f"Pin(s) {pins} and site {site} not found") + + return session_and_channel_info diff --git a/ni_measurementlink_generator/tests/test_assets/example_renders/measurement_with_annotations/measurement.py b/ni_measurementlink_generator/tests/test_assets/example_renders/measurement_with_annotations/measurement.py new file mode 100644 index 000000000..3839a3c63 --- /dev/null +++ b/ni_measurementlink_generator/tests/test_assets/example_renders/measurement_with_annotations/measurement.py @@ -0,0 +1,47 @@ +"""A default measurement with an array in and out.""" +import logging +import pathlib + +import click +import ni_measurementlink_service as nims + +service_directory = pathlib.Path(__file__).resolve().parent +measurement_service = nims.MeasurementService( + service_config_path=service_directory / "SampleMeasurement.serviceconfig", + version="1.2.3.4", + ui_file_paths=[service_directory / "MeasurementUI.measui"], +) + + +@measurement_service.register_measurement +@measurement_service.configuration("Array in", nims.DataType.DoubleArray1D, [0.0]) +@measurement_service.output("Array out", nims.DataType.DoubleArray1D) +def measure(array_input): + """TODO: replace the following line with your own measurement logic.""" + array_output = array_input + return (array_output,) + + +@click.command +@click.option( + "-v", + "--verbose", + count=True, + help="Enable verbose logging. Repeat to increase verbosity.", +) +def main(verbose: int) -> None: + """Host the Sample Measurement service.""" + if verbose > 1: + level = logging.DEBUG + elif verbose == 1: + level = logging.INFO + else: + level = logging.WARNING + logging.basicConfig(format="%(asctime)s %(levelname)s: %(message)s", level=level) + + with measurement_service.host_service(): + input("Press enter to close the measurement service.\n") + + +if __name__ == "__main__": + main() diff --git a/ni_measurementlink_generator/tests/test_assets/example_renders/measurement_with_annotations/start.bat b/ni_measurementlink_generator/tests/test_assets/example_renders/measurement_with_annotations/start.bat new file mode 100644 index 000000000..eade78764 --- /dev/null +++ b/ni_measurementlink_generator/tests/test_assets/example_renders/measurement_with_annotations/start.bat @@ -0,0 +1,6 @@ +@echo off +REM The discovery service uses this script to start the measurement service. +REM You can customize this script for your Python setup. The -v option logs +REM messages with level INFO and above. + +call python "%~dp0measurement.py" -v