From e3ca88bc782572c42d3756d2aa8c08b5a733bdac Mon Sep 17 00:00:00 2001 From: MounikaBattu17 <126869186+MounikaBattu17@users.noreply.github.com> Date: Tue, 17 Sep 2024 19:51:33 +0530 Subject: [PATCH] Add interactive mode for client generation (#883) * Feat: Add interactive mode for client generation * Fix: Lint errors * Fix: Minor .message improvements * Refractor: craeting class and module names methods * Client: Use click group as script command * Fix: Add interactive mode in existing command * Fix: Remove command name * Client: List display name instead of service class * Client: Generate default class and module name for interactive mode * Client: Refractor display messages * Client: Capiltialize the noun python * Client: Spacing in options help context * Client: Fix method signatures * Client; Fix lint errors * Client: Update directory_out default value to None * Client: Update exit message * Client: Update parameter names * Client: Use optgroup to group different modes * Client: Refractor create client method * Client: Update directory out creation * Client: Update directory out in all mode * Client: Update interactive exit message * Client: Create class & module name for multiple clients * Client: Remove exception for no service class * Packages: Add click-option-group * Client: Update context help * Client: extract create_client into helper functions * Client: Fix myPy errors * Packages: Update click-option-group version * Client: Update exit prompt message * Packages: Update lock file * Client: Update -s option case to kebab case * Client: Rearrange command signature * Client: Update create client command docstring * Client: Update prompt messages * Refractor; precise variable names * Client: Update plural varaible names * Client: Add sub helper methods * Client: Moved helper methods to support.py * Tests: update create client command in tests * Test: Update tests * Client: Add missing required argument --- .../client/__init__.py | 318 ++++++++++++------ .../client/_support.py | 117 +++++-- packages/generator/poetry.lock | 23 +- packages/generator/pyproject.toml | 1 + .../tests/acceptance/test_client_generator.py | 2 + .../test_non_streaming_measurement_client.py | 1 + .../test_pin_aware_measurement_client.py | 1 + .../test_streaming_measurement_client.py | 1 + 8 files changed, 326 insertions(+), 138 deletions(-) diff --git a/packages/generator/ni_measurement_plugin_sdk_generator/client/__init__.py b/packages/generator/ni_measurement_plugin_sdk_generator/client/__init__.py index db204cbac..01fc181f0 100644 --- a/packages/generator/ni_measurement_plugin_sdk_generator/client/__init__.py +++ b/packages/generator/ni_measurement_plugin_sdk_generator/client/__init__.py @@ -7,6 +7,7 @@ import black import click +from click_option_group import optgroup, RequiredMutuallyExclusiveOptionGroup from mako.template import Template from ni_measurement_plugin_sdk_service._internal.stubs.ni.measurementlink.measurement.v2 import ( measurement_service_pb2 as v2_measurement_service_pb2, @@ -15,16 +16,20 @@ from ni_measurement_plugin_sdk_service.grpc.channelpool import GrpcChannelPool from ni_measurement_plugin_sdk_generator.client._support import ( - camel_to_snake_case, + create_class_name, + create_module_name, + extract_base_service_class, get_configuration_metadata_by_index, get_configuration_parameters_with_type_and_default_values, get_measurement_service_stub, get_output_metadata_by_index, get_output_parameters_with_type, - get_all_registered_measurement_service_classes, - is_python_identifier, - remove_suffix, + get_all_registered_measurement_info, + get_selected_measurement_service_class, to_ordered_set, + resolve_output_directory, + validate_identifier, + validate_measurement_service_classes, ) @@ -55,25 +60,204 @@ def _create_file( file.write(formatted_output) +def _create_client( + discovery_client: DiscoveryClient, + channel_pool: GrpcChannelPool, + measurement_service_class: str, + module_name: str, + class_name: str, + directory_out: pathlib.Path, +) -> None: + built_in_import_modules: List[str] = [] + custom_import_modules: List[str] = [] + enum_values_by_type: Dict[Type[Enum], Dict[str, int]] = {} + + measurement_service_stub = get_measurement_service_stub( + discovery_client, channel_pool, measurement_service_class + ) + metadata = measurement_service_stub.GetMetadata(v2_measurement_service_pb2.GetMetadataRequest()) + configuration_metadata = get_configuration_metadata_by_index( + metadata, measurement_service_class, enum_values_by_type + ) + output_metadata = get_output_metadata_by_index(metadata, enum_values_by_type) + + configuration_parameters_with_type_and_default_values, measure_api_parameters = ( + get_configuration_parameters_with_type_and_default_values( + configuration_metadata, built_in_import_modules, enum_values_by_type + ) + ) + output_parameters_with_type = get_output_parameters_with_type( + output_metadata, built_in_import_modules, custom_import_modules, enum_values_by_type + ) + + _create_file( + template_name="measurement_plugin_client.py.mako", + file_name=f"{module_name}.py", + directory_out=directory_out, + class_name=class_name, + display_name=metadata.measurement_details.display_name, + configuration_metadata=configuration_metadata, + output_metadata=output_metadata, + service_class=measurement_service_class, + configuration_parameters_with_type_and_default_values=configuration_parameters_with_type_and_default_values, + measure_api_parameters=measure_api_parameters, + output_parameters_with_type=output_parameters_with_type, + built_in_import_modules=to_ordered_set(built_in_import_modules), + custom_import_modules=to_ordered_set(custom_import_modules), + enum_by_class_name=enum_values_by_type, + ) + + print( + f"The measurement plug-in client for the service class '{measurement_service_class}' is created successfully." + ) + + +def _create_all_clients(directory_out: Optional[str]) -> None: + channel_pool = GrpcChannelPool() + discovery_client = DiscoveryClient(grpc_channel_pool=channel_pool) + + directory_out_path = resolve_output_directory(directory_out) + measurement_service_classes, _ = get_all_registered_measurement_info(discovery_client) + validate_measurement_service_classes(measurement_service_classes) + + for service_class in measurement_service_classes: + base_service_class = extract_base_service_class(service_class) + module_name = create_module_name(base_service_class) + class_name = create_class_name(base_service_class) + validate_identifier(module_name, "module") + validate_identifier(class_name, "class") + + _create_client( + channel_pool=channel_pool, + discovery_client=discovery_client, + measurement_service_class=service_class, + module_name=module_name, + class_name=class_name, + directory_out=directory_out_path, + ) + + +def _create_clients_interactively() -> None: + print("Creating the Python Measurement Plug-In Client in interactive mode...") + channel_pool = GrpcChannelPool() + discovery_client = DiscoveryClient(grpc_channel_pool=channel_pool) + directory_out_path = resolve_output_directory() + + while True: + measurement_service_classes, measurement_display_names = ( + get_all_registered_measurement_info(discovery_client) + ) + validate_measurement_service_classes(measurement_service_classes) + + print("\nList of registered measurements:") + for index, display_name in enumerate(measurement_display_names, start=1): + print(f"{index}. {display_name}") + + selection = click.prompt( + "\nSelect a measurement to generate a client (x to exit)", + type=str, + ) + if selection.lower() == "x": + break + service_class = get_selected_measurement_service_class( + int(selection), measurement_service_classes + ) + + base_service_class = extract_base_service_class(service_class) + default_module_name = create_module_name(base_service_class) + module_name = click.prompt( + "Enter a name for the Python client module, or press Enter to use the default name.", + type=str, + default=default_module_name, + ) + validate_identifier(module_name, "module") + default_class_name = create_class_name(base_service_class) + class_name = click.prompt( + "Enter a name for the Python client class, or press Enter to use the default name.", + type=str, + default=default_class_name, + ) + validate_identifier(class_name, "class") + + _create_client( + channel_pool=channel_pool, + discovery_client=discovery_client, + measurement_service_class=service_class, + module_name=module_name, + class_name=class_name, + directory_out=directory_out_path, + ) + + +def _create_clients( + measurement_service_classes: List[str], + module_name: Optional[str], + class_name: Optional[str], + directory_out: Optional[str], +) -> None: + channel_pool = GrpcChannelPool() + discovery_client = DiscoveryClient(grpc_channel_pool=channel_pool) + directory_out_path = resolve_output_directory(directory_out) + + has_multiple_service_classes = len(measurement_service_classes) > 1 + for service_class in measurement_service_classes: + base_service_class = extract_base_service_class(service_class) + if has_multiple_service_classes or module_name is None: + module_name = create_module_name(base_service_class) + if has_multiple_service_classes or class_name is None: + class_name = create_class_name(base_service_class) + validate_identifier(module_name, "module") + validate_identifier(class_name, "class") + + _create_client( + channel_pool=channel_pool, + discovery_client=discovery_client, + measurement_service_class=service_class, + module_name=module_name, + class_name=class_name, + directory_out=directory_out_path, + ) + + @click.command() -@click.argument("measurement_service_class", nargs=-1) -@click.option( +@optgroup.group( + "all-modes", + cls=RequiredMutuallyExclusiveOptionGroup, + help="The different modes to create Python measurement client.", +) +@optgroup.option( + "-s", + "--measurement-service-class", + help="Creates Python Measurement Plug-In Client for the given measurement services.", + multiple=True, +) +@optgroup.option( + "-a", + "--all", + is_flag=True, + help="Creates Python Measurement Plug-In Clients for all registered measurement services.", +) +@optgroup.option( + "-i", + "--interactive", + is_flag=True, + help="Creates Python Measurement Plug-In Clients interactively.", +) +@optgroup.group( + "optional parameters", + help="Recommended parameters when using measurement service class mode.", +) +@optgroup.option( "-m", "--module-name", help="Name for the Python Measurement Plug-In Client module.", ) -@click.option( +@optgroup.option( "-c", "--class-name", help="Name for the Python Measurement Plug-In Client Class in the generated module.", ) -@click.option( - "-a", - "--all", - is_flag=True, - help="Creates Python Measurement Plug-In Client for all the registered measurement services.", -) -@click.option( +@optgroup.option( "-o", "--directory-out", help="Output directory for Measurement Plug-In Client files. Default: '/'", @@ -81,6 +265,7 @@ def _create_file( def create_client( measurement_service_class: List[str], all: bool, + interactive: bool, module_name: Optional[str], class_name: Optional[str], directory_out: Optional[str], @@ -90,105 +275,16 @@ def create_client( You can use the generated module to interact with the corresponding measurement service. MEASUREMENT_SERVICE_CLASS: Accepts one or more measurement service classes. - Separate each service class with a space. + Provide each service class separately. """ - channel_pool = GrpcChannelPool() - discovery_client = DiscoveryClient(grpc_channel_pool=channel_pool) - built_in_import_modules: List[str] = [] - custom_import_modules: List[str] = [] - if all: - measurement_service_class = get_all_registered_measurement_service_classes(discovery_client) - if len(measurement_service_class) == 0: - raise click.ClickException("No registered measurements.") - else: - if not measurement_service_class: - raise click.ClickException( - "The measurement service class cannot be empty. Either provide a measurement service class or use the 'all' flag to generate clients for all registered measurements." - ) - - if directory_out is None: - directory_out_path = pathlib.Path.cwd() + _create_all_clients(directory_out) + elif interactive: + _create_clients_interactively() else: - directory_out_path = pathlib.Path(directory_out) - - if not directory_out_path.exists(): - raise click.ClickException(f"The specified directory '{directory_out}' was not found.") - - is_multiple_client_generation = len(measurement_service_class) > 1 - for service_class in measurement_service_class: - enum_values_by_type: Dict[Type[Enum], Dict[str, int]] = {} - if is_multiple_client_generation or module_name is None or class_name is None: - base_service_class = service_class.split(".")[-1] - base_service_class = remove_suffix(base_service_class) - - if not base_service_class.isidentifier(): - raise click.ClickException( - "Client creation failed.\nEither provide a module name or update the measurement with a valid service class." - ) - if not any(ch.isupper() for ch in base_service_class): - print( - f"Warning: The service class '{service_class}' does not adhere to the recommended format." - ) - - if is_multiple_client_generation: - module_name = camel_to_snake_case(base_service_class) + "_client" - class_name = base_service_class.replace("_", "") + "Client" - else: - if module_name is None: - module_name = camel_to_snake_case(base_service_class) + "_client" - if class_name is None: - class_name = base_service_class.replace("_", "") + "Client" - - if not is_python_identifier(module_name): - raise click.ClickException( - f"The module name '{module_name}' is not a valid Python identifier." - ) - if not is_python_identifier(class_name): - raise click.ClickException( - f"The class name '{class_name}' is not a valid Python identifier." - ) - - measurement_service_stub = get_measurement_service_stub( - discovery_client, channel_pool, service_class - ) - metadata = measurement_service_stub.GetMetadata( - v2_measurement_service_pb2.GetMetadataRequest() - ) - configuration_metadata = get_configuration_metadata_by_index( - metadata, service_class, enum_values_by_type - ) - output_metadata = get_output_metadata_by_index(metadata, enum_values_by_type) - - configuration_parameters_with_type_and_default_values, measure_api_parameters = ( - get_configuration_parameters_with_type_and_default_values( - configuration_metadata, built_in_import_modules, enum_values_by_type - ) - ) - output_parameters_with_type = get_output_parameters_with_type( - output_metadata, - built_in_import_modules, - custom_import_modules, - enum_values_by_type, - ) - - _create_file( - template_name="measurement_plugin_client.py.mako", - file_name=f"{module_name}.py", - directory_out=directory_out_path, + _create_clients( + measurement_service_classes=measurement_service_class, + module_name=module_name, class_name=class_name, - display_name=metadata.measurement_details.display_name, - configuration_metadata=configuration_metadata, - output_metadata=output_metadata, - service_class=service_class, - configuration_parameters_with_type_and_default_values=configuration_parameters_with_type_and_default_values, - measure_api_parameters=measure_api_parameters, - output_parameters_with_type=output_parameters_with_type, - built_in_import_modules=to_ordered_set(built_in_import_modules), - custom_import_modules=to_ordered_set(custom_import_modules), - enum_by_class_name=enum_values_by_type, - ) - - print( - f"The measurement plug-in client for the service class '{service_class}' has been created successfully." + directory_out=directory_out, ) diff --git a/packages/generator/ni_measurement_plugin_sdk_generator/client/_support.py b/packages/generator/ni_measurement_plugin_sdk_generator/client/_support.py index 707f220f1..fe27def2f 100644 --- a/packages/generator/ni_measurement_plugin_sdk_generator/client/_support.py +++ b/packages/generator/ni_measurement_plugin_sdk_generator/client/_support.py @@ -3,6 +3,7 @@ import json import keyword import os +import pathlib import re import sys from enum import Enum @@ -85,15 +86,20 @@ def get_measurement_service_stub( return v2_measurement_service_pb2_grpc.MeasurementServiceStub(channel) -def get_all_registered_measurement_service_classes(discovery_client: DiscoveryClient) -> List[str]: - """Returns the service classes of all the registered measurement services.""" +def get_all_registered_measurement_info( + discovery_client: DiscoveryClient, +) -> Tuple[List[str], List[str]]: + """Returns the service classes and display names of all the registered measurement services.""" registered_measurement_services = discovery_client.enumerate_services( _V2_MEASUREMENT_SERVICE_INTERFACE ) measurement_service_classes = [ measurement_service.service_class for measurement_service in registered_measurement_services ] - return measurement_service_classes + measurement_display_names = [ + measurement_service.display_name for measurement_service in registered_measurement_services + ] + return measurement_service_classes, measurement_display_names def get_configuration_metadata_by_index( @@ -286,33 +292,68 @@ def to_ordered_set(values: Iterable[_T]) -> AbstractSet[_T]: return dict.fromkeys(values).keys() -def camel_to_snake_case(camel_case_string: str) -> str: - """Converts a camelCase string to a snake_case string.""" - partial = camel_case_string - for regex in _CAMEL_TO_SNAKE_CASE_REGEXES: - partial = regex.sub(r"\1_\2", partial) - - return partial.lower() +def resolve_output_directory(directory_out: Optional[str] = None) -> pathlib.Path: + """Returns the validated directory output path.""" + if directory_out is None: + directory_out_path = pathlib.Path.cwd() + else: + directory_out_path = pathlib.Path(directory_out) + if not directory_out_path.exists(): + raise click.ClickException(f"The specified directory '{directory_out}' was not found.") -def remove_suffix(string: str) -> str: - """Removes the suffix from the given string.""" - suffixes = ["_Python", "_LabVIEW"] - for suffix in suffixes: - if string.endswith(suffix): - if sys.version_info >= (3, 9): - return string.removesuffix(suffix) - else: - return string[0 : len(string) - len(suffix)] - return string + return directory_out_path -def is_python_identifier(input_string: Optional[str]) -> bool: +def validate_identifier(name: str, name_type: str) -> None: """Validates whether the given string is a valid Python identifier.""" - if input_string is None: - return False - pattern = r"^[a-zA-Z_][a-zA-Z0-9_]*$" - return re.fullmatch(pattern, input_string) is not None + if not _is_python_identifier(name): + raise click.ClickException( + f"The {name_type} name '{name}' is not a valid Python identifier." + ) + + +def extract_base_service_class(service_class: str) -> str: + """Creates a base service class from the measurement service class.""" + base_service_class = service_class.split(".")[-1] + base_service_class = _remove_suffix(base_service_class) + + if not base_service_class.isidentifier(): + raise click.ClickException( + "Client creation failed.\nEither provide a module name or update the measurement with a valid service class." + ) + if not any(ch.isupper() for ch in base_service_class): + print( + f"Warning: The service class '{service_class}' does not adhere to the recommended format." + ) + return base_service_class + + +def create_module_name(base_service_class: str) -> str: + """Creates a module name using base service class.""" + return _camel_to_snake_case(base_service_class) + "_client" + + +def create_class_name(base_service_class: str) -> str: + """Creates a class name using base service class.""" + return base_service_class.replace("_", "") + "Client" + + +def get_selected_measurement_service_class( + selection: int, measurement_service_classes: List[str] +) -> str: + """Returns the selected measurement service class.""" + if not (1 <= selection <= len(measurement_service_classes)): + raise click.ClickException( + f"Input {selection} is not invalid. Please try again by selecting a valid measurement from the list." + ) + return measurement_service_classes[selection - 1] + + +def validate_measurement_service_classes(measurement_service_classes: List[str]) -> None: + """Validates whether the given measurement service classes list is empty.""" + if len(measurement_service_classes) == 0: + raise click.ClickException("No registered measurements.") def _get_python_identifier(input_string: str) -> str: @@ -337,6 +378,32 @@ def _get_python_type_as_str(type: Field.Kind.ValueType, is_array: bool) -> str: return python_type.__name__ +def _camel_to_snake_case(camel_case_string: str) -> str: + partial = camel_case_string + for regex in _CAMEL_TO_SNAKE_CASE_REGEXES: + partial = regex.sub(r"\1_\2", partial) + + return partial.lower() + + +def _remove_suffix(string: str) -> str: + suffixes = ["_Python", "_LabVIEW"] + for suffix in suffixes: + if string.endswith(suffix): + if sys.version_info >= (3, 9): + return string.removesuffix(suffix) + else: + return string[0 : len(string) - len(suffix)] + return string + + +def _is_python_identifier(input_string: Optional[str]) -> bool: + if input_string is None: + return False + pattern = r"^[a-zA-Z_][a-zA-Z0-9_]*$" + return re.fullmatch(pattern, input_string) is not None + + def _is_enum_param(parameter_type: int) -> bool: return parameter_type == FieldDescriptorProto.TYPE_ENUM diff --git a/packages/generator/poetry.lock b/packages/generator/poetry.lock index 128828e47..b10d3d9d9 100644 --- a/packages/generator/poetry.lock +++ b/packages/generator/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "bandit" @@ -85,6 +85,25 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} +[[package]] +name = "click-option-group" +version = "0.5.6" +description = "Option groups missing in Click" +optional = false +python-versions = ">=3.6,<4" +files = [ + {file = "click-option-group-0.5.6.tar.gz", hash = "sha256:97d06703873518cc5038509443742b25069a3c7562d1ea72ff08bfadde1ce777"}, + {file = "click_option_group-0.5.6-py3-none-any.whl", hash = "sha256:38a26d963ee3ad93332ddf782f9259c5bdfe405e73408d943ef5e7d0c3767ec7"}, +] + +[package.dependencies] +Click = ">=7.0,<9" + +[package.extras] +docs = ["Pallets-Sphinx-Themes", "m2r2", "sphinx"] +tests = ["pytest"] +tests-cov = ["coverage", "coveralls", "pytest", "pytest-cov"] + [[package]] name = "colorama" version = "0.4.6" @@ -1087,4 +1106,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "63f83af369e27a6a82cbe0535c26bb0bdbff66262a2d5b4c644ee7d6a81d8ad5" +content-hash = "735ab9d7c16ec2994ec1a170d7e36cf817cbae62e5fa6ac7785bb835af058148" diff --git a/packages/generator/pyproject.toml b/packages/generator/pyproject.toml index b1a48df17..8a20313b2 100644 --- a/packages/generator/pyproject.toml +++ b/packages/generator/pyproject.toml @@ -25,6 +25,7 @@ click = ">=8.1.3" grpcio = "^1.49.1" protobuf = "^4.21" black = ">=24.8.0" +click-option-group = ">=0.5.6" # ni-measurement-plugin-sdk-service = {version = "^2.0.0"} [tool.poetry.group.dev.dependencies] diff --git a/packages/generator/tests/acceptance/test_client_generator.py b/packages/generator/tests/acceptance/test_client_generator.py index a19fcab49..c17a376e3 100644 --- a/packages/generator/tests/acceptance/test_client_generator.py +++ b/packages/generator/tests/acceptance/test_client_generator.py @@ -25,6 +25,7 @@ def test___command_line_args___create_client___render_without_error( with pytest.raises(SystemExit) as exc_info: create_client( [ + "--measurement-service-class", "ni.tests.NonStreamingDataMeasurement_Python", "--module-name", module_name, @@ -82,6 +83,7 @@ def test___command_line_args___create_client___render_with_proper_line_ending( with pytest.raises(SystemExit) as exc_info: create_client( [ + "--measurement-service-class", "ni.tests.NonStreamingDataMeasurement_Python", "--module-name", module_name, diff --git a/packages/generator/tests/acceptance/test_non_streaming_measurement_client.py b/packages/generator/tests/acceptance/test_non_streaming_measurement_client.py index 1430d299e..176972115 100644 --- a/packages/generator/tests/acceptance/test_non_streaming_measurement_client.py +++ b/packages/generator/tests/acceptance/test_non_streaming_measurement_client.py @@ -121,6 +121,7 @@ def measurement_client_directory( with pytest.raises(SystemExit): create_client( [ + "--measurement-service-class", "ni.tests.NonStreamingDataMeasurement_Python", "--module-name", module_name, diff --git a/packages/generator/tests/acceptance/test_pin_aware_measurement_client.py b/packages/generator/tests/acceptance/test_pin_aware_measurement_client.py index 91183a1c6..374fdbd3e 100644 --- a/packages/generator/tests/acceptance/test_pin_aware_measurement_client.py +++ b/packages/generator/tests/acceptance/test_pin_aware_measurement_client.py @@ -159,6 +159,7 @@ def measurement_client_directory( with pytest.raises(SystemExit): create_client( [ + "--measurement-service-class", "ni.tests.PinAwareMeasurement_Python", "--module-name", module_name, diff --git a/packages/generator/tests/acceptance/test_streaming_measurement_client.py b/packages/generator/tests/acceptance/test_streaming_measurement_client.py index 0dfa194f1..75d52c4fb 100644 --- a/packages/generator/tests/acceptance/test_streaming_measurement_client.py +++ b/packages/generator/tests/acceptance/test_streaming_measurement_client.py @@ -121,6 +121,7 @@ def measurement_client_directory( with pytest.raises(SystemExit): create_client( [ + "--measurement-service-class", "ni.tests.StreamingDataMeasurement_Python", "--module-name", module_name,