diff --git a/src/azure-cli-core/azure/cli/core/_help.py b/src/azure-cli-core/azure/cli/core/_help.py index d381b2236a6..e30c986addf 100644 --- a/src/azure-cli-core/azure/cli/core/_help.py +++ b/src/azure-cli-core/azure/cli/core/_help.py @@ -201,7 +201,14 @@ def strip_command(command): contents = [item for item in command.split(' ') if item] return ' '.join(contents).strip() - return [strip_command(example.command) for example in help_file.examples] + examples = [] + for example in help_file.examples: + examples.append({ + 'command': strip_command(example.command), + 'description': example.name + }) + + return examples def _register_help_loaders(self): import azure.cli.core._help_loaders as help_loaders diff --git a/src/azure-cli-core/azure/cli/core/azclierror.py b/src/azure-cli-core/azure/cli/core/azclierror.py index 295862453e8..84fbee59260 100644 --- a/src/azure-cli-core/azure/cli/core/azclierror.py +++ b/src/azure-cli-core/azure/cli/core/azclierror.py @@ -29,38 +29,58 @@ def __init__(self, error_msg, recommendation=None): # error message self.error_msg = error_msg - # set recommendations to fix the error if the message is not actionable, - # they will be printed to users after the error message, one recommendation per line + # manual recommendations provided based on developers' knowledge self.recommendations = [] self.set_recommendation(recommendation) + # AI recommendations provided by Aladdin service, with tuple form: (recommendation, description) + self.aladdin_recommendations = [] + # exception trace for the error self.exception_trace = None super().__init__(error_msg) def set_recommendation(self, recommendation): + """" Set manual recommendations for the error. + Command module or extension authors could call this method to provide recommendations, + the recommendations will be printed after the error message, one recommendation per line + """ if isinstance(recommendation, str): self.recommendations.append(recommendation) elif isinstance(recommendation, list): self.recommendations.extend(recommendation) + def set_aladdin_recommendation(self, recommendations): + """ Set aladdin recommendations for the error. + One item should be a tuple with the form: (recommendation, description) + """ + self.aladdin_recommendations.extend(recommendations) + def set_exception_trace(self, exception_trace): self.exception_trace = exception_trace def print_error(self): from azure.cli.core.azlogging import CommandLoggerContext + from azure.cli.core.style import print_styled_text with CommandLoggerContext(logger): - # print error type and error message - message = '{}: {}'.format(self.__class__.__name__, self.error_msg) - logger.error(message) + # print error message + logger.error(self.error_msg) + # print exception trace if there is if self.exception_trace: logger.exception(self.exception_trace) + # print recommendations to action if self.recommendations: for recommendation in self.recommendations: print(recommendation, file=sys.stderr) + if self.aladdin_recommendations: + print('\nTRY THIS:', file=sys.stderr) + for recommendation, description in self.aladdin_recommendations: + print_styled_text(recommendation, file=sys.stderr) + print_styled_text(description, file=sys.stderr) + def send_telemetry(self): telemetry.set_error_type(self.__class__.__name__) # endregion @@ -101,15 +121,6 @@ def send_telemetry(self): super().send_telemetry() telemetry.set_failure(self.error_msg) - def print_error(self): - from azure.cli.core.azlogging import CommandLoggerContext - with CommandLoggerContext(logger): - # print only error message (no error type) - logger.error(self.error_msg) - # print recommendations to action - if self.recommendations: - for recommendation in self.recommendations: - print(recommendation, file=sys.stderr) # endregion @@ -233,15 +244,7 @@ class UnclassifiedUserFault(UserFault): Avoid using this class unless the error can not be classified into the UserFault related specific error types. """ - def print_error(self): - from azure.cli.core.azlogging import CommandLoggerContext - with CommandLoggerContext(logger): - # print only error message (no error type) - logger.error(self.error_msg) - # print recommendations to action - if self.recommendations: - for recommendation in self.recommendations: - print(recommendation, file=sys.stderr) + pass # CLI internal error type diff --git a/src/azure-cli-core/azure/cli/core/command_recommender.py b/src/azure-cli-core/azure/cli/core/command_recommender.py index 7e96d8b3a8c..a302abbc604 100644 --- a/src/azure-cli-core/azure/cli/core/command_recommender.py +++ b/src/azure-cli-core/azure/cli/core/command_recommender.py @@ -35,7 +35,55 @@ class AladdinUserFaultType(Enum): InvalidAccountName = 'InvalidAccountName' -class CommandRecommender(): +def get_error_type(error_msg): + """The the error type of the failed command from the error message. + The error types are only consumed by aladdin service for better recommendations. + """ + + error_type = AladdinUserFaultType.Unknown + if not error_msg: + return error_type.value + + error_msg = error_msg.lower() + if 'unrecognized' in error_msg: + error_type = AladdinUserFaultType.UnrecognizedArguments + elif 'expected one argument' in error_msg or 'expected at least one argument' in error_msg \ + or 'value required' in error_msg: + error_type = AladdinUserFaultType.ExpectedArgument + elif 'misspelled' in error_msg: + error_type = AladdinUserFaultType.UnknownSubcommand + elif 'arguments are required' in error_msg or 'argument required' in error_msg: + error_type = AladdinUserFaultType.MissingRequiredParameters + if '_subcommand' in error_msg: + error_type = AladdinUserFaultType.MissingRequiredSubcommand + elif '_command_package' in error_msg: + error_type = AladdinUserFaultType.UnableToParseCommandInput + elif 'not found' in error_msg or 'could not be found' in error_msg \ + or 'resource not found' in error_msg: + error_type = AladdinUserFaultType.AzureResourceNotFound + if 'storage_account' in error_msg or 'storage account' in error_msg: + error_type = AladdinUserFaultType.StorageAccountNotFound + elif 'resource_group' in error_msg or 'resource group' in error_msg: + error_type = AladdinUserFaultType.ResourceGroupNotFound + elif 'pattern' in error_msg or 'is not a valid value' in error_msg or 'invalid' in error_msg: + error_type = AladdinUserFaultType.InvalidParameterValue + if 'jmespath_type' in error_msg: + error_type = AladdinUserFaultType.InvalidJMESPathQuery + elif 'datetime_type' in error_msg: + error_type = AladdinUserFaultType.InvalidDateTimeArgumentValue + elif '--output' in error_msg: + error_type = AladdinUserFaultType.InvalidOutputType + elif 'resource_group' in error_msg: + error_type = AladdinUserFaultType.InvalidResourceGroupName + elif 'storage_account' in error_msg: + error_type = AladdinUserFaultType.InvalidAccountName + elif "validation error" in error_msg: + error_type = AladdinUserFaultType.ValidationError + + return error_type.value + + +class CommandRecommender(): # pylint: disable=too-few-public-methods """Recommend a command for user when user's command fails. It combines Aladdin recommendations and examples in help files.""" @@ -57,18 +105,23 @@ def __init__(self, command, parameters, extension, error_msg, cli_ctx): self.extension = extension self.error_msg = error_msg self.cli_ctx = cli_ctx - + # the item is a dict with the form {'command': #, 'description': #} self.help_examples = [] + # the item is a dict with the form {'command': #, 'description': #, 'link': #} self.aladdin_recommendations = [] def set_help_examples(self, examples): - """Set recommendations from help files""" + """Set help examples. + + :param examples: The examples from CLI help file. + :type examples: list + """ self.help_examples.extend(examples) - def _set_aladdin_recommendations(self): - """Set recommendations from aladdin service. - Call the aladdin service API, parse the response and set the recommendations. + def _set_aladdin_recommendations(self): # pylint: disable=too-many-locals + """Set Aladdin recommendations. + Call the API, parse the response and set aladdin_recommendations. """ import hashlib @@ -91,7 +144,7 @@ def _set_aladdin_recommendations(self): } context = { 'versionNumber': version, - 'errorType': self._get_error_type() + 'errorType': get_error_type(self.error_msg) } if telemetry.is_telemetry_enabled(): @@ -127,93 +180,163 @@ def _set_aladdin_recommendations(self): recommendations = [] if response and response.status_code == HTTPStatus.OK: for result in response.json(): - # parse the response and format the recommendation - command, parameters, placeholders = result['command'],\ - result['parameters'].split(','),\ - result['placeholders'].split('♠') - recommendation = 'az {} '.format(command) - for parameter, placeholder in zip(parameters, placeholders): - recommendation += '{} {} '.format(parameter, placeholder) - recommendations.append(recommendation.strip()) + # parse the response to get the raw command + raw_command = 'az {} '.format(result['command']) + for parameter, placeholder in zip(result['parameters'].split(','), result['placeholders'].split('♠')): + raw_command += '{} {}{}'.format(parameter, placeholder, ' ' if placeholder else '') + + # format the recommendation + recommendation = { + 'command': raw_command.strip(), + 'description': result['description'], + 'link': result['link'] + } + recommendations.append(recommendation) self.aladdin_recommendations.extend(recommendations) - def recommend_a_command(self): - """Recommend a command for user when user's command fails. - The recommended command will be the best matched one from - both the help files and the aladdin recommendations. + def provide_recommendations(self): + """Provide recommendations when a command fails. + + The recommendations are either from Aladdin service or CLI help examples, + which include both commands and reference links along with their descriptions. + + :return: The decorated recommendations + :type: list """ - def get_sorted_candidates(target_args, candidate_args_list): - """Get the sorted candidates by target arguments""" + from azure.cli.core.style import Style, highlight_command + from azure.cli.core.parser import OVERVIEW_REFERENCE + + def sort_recommendations(recommendations): + """Sort the recommendations by parameter matching. + + The sorting rules below are applied in order: + 1. Commands starting with the user's input command name are ahead of those don't + 2. Commands having more matched arguments are ahead of those having less + 3. Commands having less arguments are ahead of those having more + + :param recommendations: The unordered recommendations + :type recommendations: list + :return: The ordered recommendations + :type: list + """ candidates = [] - for index, candidate_args in enumerate(candidate_args_list): + target_arg_list = self._normalize_parameters(self.parameters) + for recommendation in recommendations: matches = 0 - for arg in candidate_args: - if arg in target_args: - matches += 1 + arg_list = self._normalize_parameters(recommendation['command'].split(' ')) + + # ignore commands that do not start with the use's input command name + if recommendation['command'].startswith('az {}'.format(self.command)): + for arg in arg_list: + if arg in target_arg_list: + matches += 1 + else: + matches = -1 + candidates.append({ - 'candidate_args': candidate_args, - 'index': index, + 'recommendation': recommendation, + 'arg_list': arg_list, 'matches': matches }) # sort the candidates by the number of matched arguments and total arguments - candidates.sort(key=lambda item: (item['matches'], -len(item['candidate_args'])), reverse=True) + candidates.sort(key=lambda item: (item['matches'], -len(item['arg_list'])), reverse=True) + + return [candidate['recommendation'] for candidate in candidates] + + def replace_param_values(command): # pylint: disable=unused-variable + """Replace the parameter values in a command with user's input values - return candidates + :param command: The command whose parameter value needs to be replaced + :type command: str + :return: The command with parameter values being replaced + :type: str + """ + + # replace the parameter values only when the recommended + # command's name is the same with user's input command name + if not command.startswith('az {}'.format(self.command)): + return command + + source_kwargs = get_parameter_kwargs(self.parameters) + param_mappings = self._get_param_mappings() + + return replace_parameter_values(command, source_kwargs, param_mappings) + + # do not recommend commands if it is disabled by config + if self.cli_ctx and self.cli_ctx.config.get('core', 'error_recommendation', 'on').upper() == 'OFF': + return [] # get recommendations from Aladdin service if not self._disable_aladdin_service(): self._set_aladdin_recommendations() - recommend_command = '' - if self.help_examples and self.aladdin_recommendations: - # all the recommended commands from help examples and aladdin - all_commands = self.help_examples + self.aladdin_recommendations + # recommendations are either all from Aladdin or all from help examples + recommendations = self.aladdin_recommendations + if not recommendations: + recommendations = self.help_examples - candidate_commands = [] - candidate_args_list = [] - target_args = self._normalize_parameters(self.parameters) - example_command_name = self.help_examples[0].split(' -')[0] + # sort the recommendations by parameter matching, get the top 3 recommended commands + recommendations = sort_recommendations(recommendations)[:3] - for command in all_commands: - # keep only the commands which begin with a same command name with examples - if command.startswith(example_command_name): - candidate_args_list.append(self._normalize_parameters(command.split(' '))) - candidate_commands.append(command) + raw_commands = [] + decorated_recommendations = [] + for recommendation in recommendations: + # generate raw commands recorded in Telemetry + raw_command = recommendation['command'] + raw_commands.append(raw_command) + + # disable the parameter replacement feature because it will make command description inaccurate + # raw_command = replace_param_values(raw_command) + + # generate decorated commands shown to users + decorated_command = highlight_command(raw_command) + decorated_description = [(Style.SECONDARY, recommendation['description'] + '\n')] + decorated_recommendations.append((decorated_command, decorated_description)) + + # add reference link as a recommendation + decorated_link = [(Style.HYPERLINK, OVERVIEW_REFERENCE)] + if self.aladdin_recommendations: + decorated_link = [(Style.HYPERLINK, self.aladdin_recommendations[0]['link'])] + + decorated_description = [(Style.SECONDARY, 'Read more about the command in reference docs')] + decorated_recommendations.append((decorated_link, decorated_description)) - # sort the candidates by the number of matched arguments and total arguments - candidates = get_sorted_candidates(target_args, candidate_args_list) - if candidates: - index = candidates[0]['index'] - recommend_command = candidate_commands[index] - - # fallback to use the first recommended command from Aladdin - elif self.aladdin_recommendations: - recommend_command = self.aladdin_recommendations[0] # set the recommend command into Telemetry - self._set_recommended_command_to_telemetry(recommend_command) - # replace the parameter values - recommend_command = self._replace_parameter_values(recommend_command) + self._set_recommended_command_to_telemetry(raw_commands) + + return decorated_recommendations - return recommend_command + def _set_recommended_command_to_telemetry(self, raw_commands): + """Set the recommended commands to Telemetry - def _set_recommended_command_to_telemetry(self, recommend_command): - """Set the recommended command to Telemetry for analysis. """ + Aladdin recommended commands and commands from CLI help examples are + set to different properties in Telemetry. + + :param raw_commands: The recommended raw commands + :type raw_commands: list + """ - if recommend_command in self.aladdin_recommendations: - telemetry.set_debug_info('AladdinRecommendCommand', recommend_command) - elif recommend_command: - telemetry.set_debug_info('ExampleRecommendCommand', recommend_command) + if self.aladdin_recommendations: + telemetry.set_debug_info('AladdinRecommendCommand', ';'.join(raw_commands)) + else: + telemetry.set_debug_info('ExampleRecommendCommand', ';'.join(raw_commands)) def _disable_aladdin_service(self): """Decide whether to disable aladdin request when a command fails. + The possible cases to disable it are: - 1. In air-gapped clouds - 2. In testing environments + 1. CLI context is missing + 2. In air-gapped clouds + 3. In testing environments + + :return: whether Aladdin service need to be disabled or not + :type: bool """ + from azure.cli.core.cloud import CLOUDS_FORBIDDING_ALADDIN_REQUEST # CLI is not started well @@ -230,59 +353,27 @@ def _disable_aladdin_service(self): return False - def _get_parameter_mappings(self): - """Get the short option to long option mappings of a command. """ - - from knack.deprecation import Deprecated - - try: - cmd_table = self.cli_ctx.invocation.commands_loader.command_table.get(self.command, None) - parameter_table = cmd_table.arguments if cmd_table else None - except AttributeError: - parameter_table = None - - param_mappings = { - '-h': '--help', - '-o': '--output', - '--only-show-errors': None, - '--help': None, - '--output': None, - '--query': None, - '--debug': None, - '--verbose': None - } - - if parameter_table: - for argument in parameter_table.values(): - options = argument.type.settings['options_list'] - options = [option for option in options if not isinstance(option, Deprecated)] - # skip the positional arguments - if not options: - continue - try: - sorted_options = sorted(options, key=len, reverse=True) - standard_form = sorted_options[0] - - for option in sorted_options[1:]: - param_mappings[option] = standard_form - param_mappings[standard_form] = standard_form - except TypeError: - logger.debug('Unexpected argument options `%s` of type `%s`.', options, type(options).__name__) - - return param_mappings - - def _normalize_parameters(self, raw_parameters): + def _normalize_parameters(self, args): """Normalize a parameter list. - Get the standard parameter names of the raw parameters, which includes: + + Get the standard parameter name list of the raw parameters, which includes: 1. Use long options to replace short options 2. Remove the unrecognized parameter names - An example: ['-g', 'RG', '-n', 'NAME'] ==> {'--resource-group': 'RG', '--name': 'NAME'} + 3. Sort the parameter names by their lengths + An example: ['-g', 'RG', '-n', 'NAME'] ==> ['--resource-group', '--name'] + + :param args: The raw arg list of a command + :type args: list + :return: A standard, valid and sorted parameter name list + :type: list """ - parameters = self._extract_parameter_names(raw_parameters) + from azure.cli.core.commands import AzCliCommandInvoker + + parameters = AzCliCommandInvoker._extract_parameter_names(args) # pylint: disable=protected-access normalized_parameters = [] - param_mappings = self._get_parameter_mappings() + param_mappings = self._get_param_mappings() for parameter in parameters: if parameter in param_mappings: normalized_form = param_mappings.get(parameter, None) or parameter @@ -292,119 +383,133 @@ def _normalize_parameters(self, raw_parameters): return sorted(normalized_parameters) - def _get_error_type(self): - """The the error type of the failed command from the error message. - The error types are only consumed by aladdin service for better recommendations. - """ - - error_type = AladdinUserFaultType.Unknown - if not self.error_msg: - return error_type.value - - error_msg = self.error_msg.lower() - if 'unrecognized' in error_msg: - error_type = AladdinUserFaultType.UnrecognizedArguments - elif 'expected one argument' in error_msg or 'expected at least one argument' in error_msg \ - or 'value required' in error_msg: - error_type = AladdinUserFaultType.ExpectedArgument - elif 'misspelled' in error_msg: - error_type = AladdinUserFaultType.UnknownSubcommand - elif 'arguments are required' in error_msg or 'argument required' in error_msg: - error_type = AladdinUserFaultType.MissingRequiredParameters - if '_subcommand' in error_msg: - error_type = AladdinUserFaultType.MissingRequiredSubcommand - elif '_command_package' in error_msg: - error_type = AladdinUserFaultType.UnableToParseCommandInput - elif 'not found' in error_msg or 'could not be found' in error_msg \ - or 'resource not found' in error_msg: - error_type = AladdinUserFaultType.AzureResourceNotFound - if 'storage_account' in error_msg or 'storage account' in error_msg: - error_type = AladdinUserFaultType.StorageAccountNotFound - elif 'resource_group' in error_msg or 'resource group' in error_msg: - error_type = AladdinUserFaultType.ResourceGroupNotFound - elif 'pattern' in error_msg or 'is not a valid value' in error_msg or 'invalid' in error_msg: - error_type = AladdinUserFaultType.InvalidParameterValue - if 'jmespath_type' in error_msg: - error_type = AladdinUserFaultType.InvalidJMESPathQuery - elif 'datetime_type' in error_msg: - error_type = AladdinUserFaultType.InvalidDateTimeArgumentValue - elif '--output' in error_msg: - error_type = AladdinUserFaultType.InvalidOutputType - elif 'resource_group' in error_msg: - error_type = AladdinUserFaultType.InvalidResourceGroupName - elif 'storage_account' in error_msg: - error_type = AladdinUserFaultType.InvalidAccountName - elif "validation error" in error_msg: - error_type = AladdinUserFaultType.ValidationError - - return error_type.value - - def _extract_parameter_names(self, parameters): # pylint: disable=no-self-use - """Extract parameter names from the raw parameters. - An example: ['-g', 'RG', '-n', 'NAME'] ==> ['-g', '-n'] - """ - - from azure.cli.core.commands import AzCliCommandInvoker - - return AzCliCommandInvoker._extract_parameter_names(parameters) # pylint: disable=protected-access - - def _replace_parameter_values(self, command): - """Replace the parameter values in recommended command with values in user's command - An example: - recommended command: 'az vm create -n MyVm -g MyResourceGroup --image CentOS' - user's command: 'az vm create --name user_vm -g user_rg' - ==> 'az vm create -n user_vm -g user_rg --image CentOS' + def _get_param_mappings(self): + try: + cmd_table = self.cli_ctx.invocation.commands_loader.command_table.get(self.command, None) + except AttributeError: + cmd_table = None + + return get_parameter_mappings(cmd_table) + + +def get_parameter_mappings(command_table): + """Get the short option to long option mappings of a command + + :param parameter_table: CLI command object + :type parameter_table: knack.commands.CLICommand + :param command_name: The command name + :type command name: str + :return: The short to long option mappings of the parameters + :type: dict + """ + + from knack.deprecation import Deprecated + + parameter_table = None + if hasattr(command_table, 'arguments'): + parameter_table = command_table.arguments + + param_mappings = { + '-h': '--help', + '-o': '--output', + '--only-show-errors': None, + '--help': None, + '--output': None, + '--query': None, + '--debug': None, + '--verbose': None, + '--yes': None, + '--no-wait': None + } + + if parameter_table: + for argument in parameter_table.values(): + options = argument.type.settings['options_list'] + options = [option for option in options if not isinstance(option, Deprecated)] + # skip the positional arguments + if not options: + continue + try: + sorted_options = sorted(options, key=len, reverse=True) + standard_form = sorted_options[0] + + for option in sorted_options[1:]: + param_mappings[option] = standard_form + param_mappings[standard_form] = standard_form + except TypeError: + logger.debug('Unexpected argument options `%s` of type `%s`.', options, type(options).__name__) + + return param_mappings + + +def get_parameter_kwargs(args): + """Get parameter name-value mappings from the raw arg list + An example: ['-g', 'RG', '--name=NAME'] ==> {'-g': 'RG', '--name': 'NAME'} + + :param args: The raw arg list of a command + :type args: list + :return: The parameter name-value mappings + :type: dict + """ + + parameter_kwargs = dict() + for index, parameter in enumerate(args): + if parameter.startswith('-'): + + param_name, param_val = parameter, None + if '=' in parameter: + pieces = parameter.split('=') + param_name, param_val = pieces[0], pieces[1] + elif index + 1 < len(args) and not args[index + 1].startswith('-'): + param_val = args[index + 1] + + if param_val is not None and ' ' in param_val: + param_val = '"{}"'.format(param_val) + parameter_kwargs[param_name] = param_val + + return parameter_kwargs + + +def replace_parameter_values(target_command, source_kwargs, param_mappings): + """Replace the parameter values in target_command with values in source_kwargs + + :param target_command: The command in which the parameter values need to be replaced + :type target_command: str + :param source_kwargs: The source key-val pairs used to replace the values + :type source_kwargs: dict + :param param_mappings: The short-long option mappings in terms of the target_command + :type param_mappings: dict + :returns: The target command with parameter values being replaced + :type: str + """ + + def get_user_param_value(target_param): + """Get the value that is used as the replaced value of target_param + + :param target_param: The parameter name whose value needs to be replaced + :type target_param: str + :return: The replaced value for target_param + :type: str """ + standard_source_kwargs = dict() - def get_parameter_kwargs(parameters): - """Get name value mappings from parameter list - An example: - ['-g', 'RG', '--name=NAME'] ==> {'-g': 'RG', '--name': 'NAME'} - """ - - parameter_kwargs = dict() - for index, parameter in enumerate(parameters): - if parameter.startswith('-'): - - param_name, param_val = parameter, None - if '=' in parameter: - pieces = parameter.split('=') - param_name, param_val = pieces[0], pieces[1] - elif index + 1 < len(parameters) and not parameters[index + 1].startswith('-'): - param_val = parameters[index + 1] + for param, val in source_kwargs.items(): + if param in param_mappings: + standard_param = param_mappings[param] + standard_source_kwargs[standard_param] = val - parameter_kwargs[param_name] = param_val + if target_param in param_mappings: + standard_target_param = param_mappings[target_param] + if standard_target_param in standard_source_kwargs: + return standard_source_kwargs[standard_target_param] - return parameter_kwargs + return None - def get_user_param_value(target_param, user_kwargs, param_mappings): - """Get user's input value for the target_param. """ - - standard_user_kwargs = dict() - - for param, val in user_kwargs.items(): - if param in param_mappings: - standard_param = param_mappings[param] - standard_user_kwargs[standard_param] = val - - if target_param in param_mappings: - standard_target_param = param_mappings[target_param] - if standard_target_param in standard_user_kwargs: - return standard_user_kwargs[standard_target_param] - - return None - - user_kwargs = get_parameter_kwargs(self.parameters) - param_mappings = self._get_parameter_mappings() - - command_args = command.split(' ') - for index, arg in enumerate(command_args): - if arg.startswith('-') and index + 1 < len(command_args) and not command_args[index + 1].startswith('-'): - - user_param_val = get_user_param_value(arg, user_kwargs, param_mappings) - if user_param_val: - command_args[index + 1] = user_param_val - else: - command_args[index + 1] = '<{}>'.format(command_args[index + 1]) + command_args = target_command.split(' ') + for index, arg in enumerate(command_args): + if arg.startswith('-') and index + 1 < len(command_args) and not command_args[index + 1].startswith('-'): + user_param_val = get_user_param_value(arg) + if user_param_val: + command_args[index + 1] = user_param_val - return ' '.join(command_args) + return ' '.join(command_args) diff --git a/src/azure-cli-core/azure/cli/core/parser.py b/src/azure-cli-core/azure/cli/core/parser.py index 030e295103b..f8933924de1 100644 --- a/src/azure-cli-core/azure/cli/core/parser.py +++ b/src/azure-cli-core/azure/cli/core/parser.py @@ -35,8 +35,7 @@ "To learn more about extensions, please visit " "'https://docs.microsoft.com/cli/azure/azure-cli-extensions-overview'") -OVERVIEW_REFERENCE = ("Still stuck? Run '{command} --help' to view all commands or go to " - "'https://aka.ms/cli_ref' to learn more") +OVERVIEW_REFERENCE = ("https://aka.ms/cli_ref") class IncorrectUsageError(CLIError): @@ -162,7 +161,7 @@ def error(self, message): cli_ctx = self.cli_ctx or (self.cli_help.cli_ctx if self.cli_help else None) recommender = CommandRecommender(*command_arguments, message, cli_ctx) recommender.set_help_examples(self.get_examples(self.prog)) - recommendation = recommender.recommend_a_command() + recommendations = recommender.provide_recommendations() az_error = ArgumentUsageError(message) if 'unrecognized arguments' in message: @@ -175,9 +174,8 @@ def error(self, message): if '--query' in message: from azure.cli.core.util import QUERY_REFERENCE az_error.set_recommendation(QUERY_REFERENCE) - elif recommendation: - az_error.set_recommendation("Try this: '{}'".format(recommendation)) - az_error.set_recommendation(OVERVIEW_REFERENCE.format(command=self.prog)) + elif recommendations: + az_error.set_aladdin_recommendation(recommendations) az_error.print_error() az_error.send_telemetry() self.exit(2) @@ -507,9 +505,9 @@ def _check_value(self, action, value): # pylint: disable=too-many-statements, t if not caused_by_extension_not_installed: recommender = CommandRecommender(*command_arguments, error_msg, cli_ctx) recommender.set_help_examples(self.get_examples(command_name_inferred)) - recommended_command = recommender.recommend_a_command() - if recommended_command: - az_error.set_recommendation("Try this: '{}'".format(recommended_command)) + recommendations = recommender.provide_recommendations() + if recommendations: + az_error.set_aladdin_recommendation(recommendations) # remind user to check extensions if we can not find a command to recommend if isinstance(az_error, CommandNotFoundError) \ @@ -517,8 +515,6 @@ def _check_value(self, action, value): # pylint: disable=too-many-statements, t and use_dynamic_install == 'no': az_error.set_recommendation(EXTENSION_REFERENCE) - az_error.set_recommendation(OVERVIEW_REFERENCE.format(command=self.prog)) - az_error.print_error() az_error.send_telemetry() diff --git a/src/azure-cli-core/azure/cli/core/tests/test_command_recommender.py b/src/azure-cli-core/azure/cli/core/tests/test_command_recommender.py new file mode 100644 index 00000000000..53238e5397d --- /dev/null +++ b/src/azure-cli-core/azure/cli/core/tests/test_command_recommender.py @@ -0,0 +1,110 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import unittest + + +class TestCommandRecommender(unittest.TestCase): + + @staticmethod + def sample_command(arg1, arg2): + pass + + def test_get_error_type(self): + from azure.cli.core.command_recommender import AladdinUserFaultType, get_error_type + + error_msg_pairs = [ + ('unrecognized', AladdinUserFaultType.UnrecognizedArguments), + ('expected one argument', AladdinUserFaultType.ExpectedArgument), + ('expected at least one argument', AladdinUserFaultType.ExpectedArgument), + ('misspelled', AladdinUserFaultType.UnknownSubcommand), + ('arguments are required', AladdinUserFaultType.MissingRequiredParameters), + ('argument required', AladdinUserFaultType.MissingRequiredParameters), + ('argument required: _subcommand', AladdinUserFaultType.MissingRequiredSubcommand), + ('argument required: _command_package', AladdinUserFaultType.UnableToParseCommandInput), + ('not found', AladdinUserFaultType.AzureResourceNotFound), + ('could not be found', AladdinUserFaultType.AzureResourceNotFound), + ('resource not found', AladdinUserFaultType.AzureResourceNotFound), + ('resource not found: storage_account', AladdinUserFaultType.StorageAccountNotFound), + ('resource not found: resource_group', AladdinUserFaultType.ResourceGroupNotFound), + ('pattern', AladdinUserFaultType.InvalidParameterValue), + ('is not a valid value', AladdinUserFaultType.InvalidParameterValue), + ('invalid', AladdinUserFaultType.InvalidParameterValue), + ('is not a valid value: jmespath_type', AladdinUserFaultType.InvalidJMESPathQuery), + ('is not a valid value: datetime_type', AladdinUserFaultType.InvalidDateTimeArgumentValue), + ('is not a valid value: --output', AladdinUserFaultType.InvalidOutputType), + ('is not a valid value: resource_group', AladdinUserFaultType.InvalidResourceGroupName), + ('is not a valid value: storage_account', AladdinUserFaultType.InvalidAccountName), + ('validation error', AladdinUserFaultType.ValidationError) + ] + + for error_msg, expected_error_type in error_msg_pairs: + result_error_type = get_error_type(error_msg) + self.assertEqual(result_error_type, expected_error_type.value) + + def test_get_parameter_mappings(self): + import mock + from azure.cli.core import AzCommandsLoader + from azure.cli.core.mock import DummyCli + from azure.cli.core.command_recommender import get_parameter_mappings + + def _prepare_test_commands_loader(loader_cls, cli_ctx, command): + loader = loader_cls(cli_ctx) + loader.cli_ctx.invocation = mock.MagicMock() + loader.cli_ctx.invocation.commands_loader = loader + loader.command_name = command + loader.load_command_table(None) + loader.load_arguments(loader.command_name) + loader._update_command_definitions() + return loader + + class TestCommandsLoader(AzCommandsLoader): + + def load_command_table(self, args): + super(TestCommandsLoader, self).load_command_table(args) + with self.command_group('test group', operations_tmpl='{}#TestCommandRecommender.{{}}'.format(__name__)) as g: + g.command('cmd', 'sample_command') + return self.command_table + + def load_arguments(self, command): + super(TestCommandsLoader, self).load_arguments(command) + with self.argument_context('test group cmd') as c: + c.argument('arg1', options_list=('--arg1', '--arg1-alias', '-a')) + c.argument('arg2', options_list=('--arg2', '--arg2-alias', '--arg2-alias-long')) + + cli = DummyCli(commands_loader_cls=TestCommandsLoader) + command = 'test group cmd' + loader = _prepare_test_commands_loader(TestCommandsLoader, cli, command) + param_mappings = get_parameter_mappings(loader.command_table[command]) + + common = { + '-h': '--help', + '-o': '--output', + '--only-show-errors': None, + '--help': None, + '--output': None, + '--query': None, + '--debug': None, + '--verbose': None + } + + expected = { + '-a': '--arg1-alias', + '--arg1': '--arg1-alias', + '--arg1-alias': '--arg1-alias', + '--arg2': '--arg2-alias-long', + '--arg2-alias': '--arg2-alias-long', + '--arg2-alias-long': '--arg2-alias-long' + } + + for key, val in common.items(): + self.assertEqual(param_mappings.get(key), val) + + for key, val in expected.items(): + self.assertEqual(param_mappings.get(key), val) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/azure-cli-core/azure/cli/core/tests/test_parser.py b/src/azure-cli-core/azure/cli/core/tests/test_parser.py index 0e2e43ffa76..9f60fd6dc98 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_parser.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_parser.py @@ -239,7 +239,7 @@ def mock_add_extension(*args, **kwargs): # assert the right type of error msg is logged for command vs argument parsing self.assertEqual(len(logger_msgs), 5) for msg in logger_msgs[:3]: - self.assertIn("CommandNotFoundError", msg) + self.assertIn("misspelled or not recognized by the system", msg) for msg in logger_msgs[3:]: self.assertIn("not a valid value for '--opt'.", msg)