From 2d016aa8702c4f37c0560b8d72cc285539d4a3b0 Mon Sep 17 00:00:00 2001 From: houk-ms Date: Tue, 8 Dec 2020 17:17:41 +0800 Subject: [PATCH 01/10] initial version for new error output --- src/azure-cli-core/azure/cli/core/_help.py | 2 +- .../azure/cli/core/azclierror.py | 48 +++++----- .../azure/cli/core/command_recommender.py | 96 +++++-------------- src/azure-cli-core/azure/cli/core/parser.py | 24 ++--- .../azure/cli/core/tests/test_parser.py | 2 +- 5 files changed, 58 insertions(+), 114 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_help.py b/src/azure-cli-core/azure/cli/core/_help.py index d1e568bfb8d..ebcbfb0c2c4 100644 --- a/src/azure-cli-core/azure/cli/core/_help.py +++ b/src/azure-cli-core/azure/cli/core/_help.py @@ -201,7 +201,7 @@ 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] + return [(strip_command(example.command), example.name) for example in help_file.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..0ac6b96fcae 100644 --- a/src/azure-cli-core/azure/cli/core/azclierror.py +++ b/src/azure-cli-core/azure/cli/core/azclierror.py @@ -29,38 +29,57 @@ 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 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(recommendation, file=sys.stderr) + print(description + '\n', file=sys.stderr) + def send_telemetry(self): telemetry.set_error_type(self.__class__.__name__) # endregion @@ -101,15 +120,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 +243,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..4705906f4d0 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,7 @@ class AladdinUserFaultType(Enum): InvalidAccountName = 'InvalidAccountName' -class CommandRecommender(): +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.""" @@ -58,15 +58,10 @@ def __init__(self, command, parameters, extension, error_msg, cli_ctx): self.error_msg = error_msg self.cli_ctx = cli_ctx - self.help_examples = [] + # item is a tuple with the form: (command, description, link) self.aladdin_recommendations = [] - def set_help_examples(self, examples): - """Set recommendations from help files""" - - self.help_examples.extend(examples) - - def _set_aladdin_recommendations(self): + def _set_aladdin_recommendations(self): # pylint: disable=too-many-locals """Set recommendations from aladdin service. Call the aladdin service API, parse the response and set the recommendations. """ @@ -127,86 +122,45 @@ def _set_aladdin_recommendations(self): recommendations = [] if response and response.status_code == HTTPStatus.OK: for result in response.json(): + print(response.json()) # parse the response and format the recommendation - command, parameters, placeholders = result['command'],\ + command, description, parameters, placeholders, link = \ + result['command'],\ + 'A mocked description',\ result['parameters'].split(','),\ - result['placeholders'].split('♠') - recommendation = 'az {} '.format(command) + result['placeholders'].split('♠'),\ + 'A mocked link' + recommended_command = 'az {} '.format(command) for parameter, placeholder in zip(parameters, placeholders): - recommendation += '{} {} '.format(parameter, placeholder) - recommendations.append(recommendation.strip()) + recommended_command += '{} {}{}'.format(parameter, placeholder, ' ' if placeholder else '') + recommendations.append((recommended_command.strip(), description, link)) 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 from Aladdin service, + which include both commands and reference link along with their descriptions. """ - def get_sorted_candidates(target_args, candidate_args_list): - """Get the sorted candidates by target arguments""" - - candidates = [] - for index, candidate_args in enumerate(candidate_args_list): - matches = 0 - for arg in candidate_args: - if arg in target_args: - matches += 1 - candidates.append({ - 'candidate_args': candidate_args, - 'index': index, - '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) - - return candidates - # 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 - - candidate_commands = [] - candidate_args_list = [] - target_args = self._normalize_parameters(self.parameters) - example_command_name = self.help_examples[0].split(' -')[0] - - 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) - - # 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] + recommendations = [(item[0], item[1]) for item in self.aladdin_recommendations] + recommended_commands = [item[0] for item in recommendations] + if self.aladdin_recommendations: + _, _, link = self.aladdin_recommendations[0] + recommendations.append((link, 'Read more about the command in reference docs')) + # 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(recommended_commands) - return recommend_command + return recommendations - def _set_recommended_command_to_telemetry(self, recommend_command): + def _set_recommended_command_to_telemetry(self, recommended_commands): # pylint: disable=no-self-use """Set the recommended command to Telemetry for analysis. """ - if recommend_command in self.aladdin_recommendations: - telemetry.set_debug_info('AladdinRecommendCommand', recommend_command) - elif recommend_command: - telemetry.set_debug_info('ExampleRecommendCommand', recommend_command) + telemetry.set_debug_info('AladdinRecommendCommand', ';'.join(recommended_commands)) def _disable_aladdin_service(self): """Decide whether to disable aladdin request when a command fails. diff --git a/src/azure-cli-core/azure/cli/core/parser.py b/src/azure-cli-core/azure/cli/core/parser.py index 58c1d60d9e9..aa0da535cb5 100644 --- a/src/azure-cli-core/azure/cli/core/parser.py +++ b/src/azure-cli-core/azure/cli/core/parser.py @@ -161,8 +161,7 @@ def error(self, message): command_arguments = self._get_failure_recovery_arguments() 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) @@ -374,16 +372,9 @@ def _check_value(self, action, value): # pylint: disable=too-many-statements, t cli_ctx = self.cli_ctx or (self.cli_help.cli_ctx if self.cli_help else None) caused_by_extension_not_installed = False - command_name_inferred = self.prog error_msg = None if not self.command_source: candidates = difflib.get_close_matches(value, action.choices, cutoff=0.7) - if candidates: - # use the most likely candidate to replace the misspelled command - args = self.prog.split() + self._raw_arguments - args_inferred = [item if item != value else candidates[0] for item in args] - command_name_inferred = ' '.join(args_inferred).split('-')[0] - use_dynamic_install = self._get_extension_use_dynamic_install_config() if use_dynamic_install != 'no' and not candidates: # Check if the command is from an extension @@ -458,10 +449,9 @@ def _check_value(self, action, value): # pylint: disable=too-many-statements, t # recommend a command for user 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) \ @@ -469,8 +459,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)) - if not caused_by_extension_not_installed: az_error.print_error() az_error.send_telemetry() 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) From 74e9297158fa158a34080539e340478b26565b05 Mon Sep 17 00:00:00 2001 From: houk-ms Date: Mon, 14 Dec 2020 11:00:22 +0800 Subject: [PATCH 02/10] apply style in recommendations --- .../azure/cli/core/azclierror.py | 5 +- .../azure/cli/core/command_recommender.py | 91 ++++++++++++++----- 2 files changed, 71 insertions(+), 25 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/azclierror.py b/src/azure-cli-core/azure/cli/core/azclierror.py index 0ac6b96fcae..84fbee59260 100644 --- a/src/azure-cli-core/azure/cli/core/azclierror.py +++ b/src/azure-cli-core/azure/cli/core/azclierror.py @@ -61,6 +61,7 @@ def set_exception_trace(self, 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 message logger.error(self.error_msg) @@ -77,8 +78,8 @@ def print_error(self): if self.aladdin_recommendations: print('\nTRY THIS:', file=sys.stderr) for recommendation, description in self.aladdin_recommendations: - print(recommendation, file=sys.stderr) - print(description + '\n', file=sys.stderr) + 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__) 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 4705906f4d0..a1307f348b6 100644 --- a/src/azure-cli-core/azure/cli/core/command_recommender.py +++ b/src/azure-cli-core/azure/cli/core/command_recommender.py @@ -122,18 +122,15 @@ def _set_aladdin_recommendations(self): # pylint: disable=too-many-locals recommendations = [] if response and response.status_code == HTTPStatus.OK: for result in response.json(): - print(response.json()) # parse the response and format the recommendation - command, description, parameters, placeholders, link = \ - result['command'],\ - 'A mocked description',\ - result['parameters'].split(','),\ - result['placeholders'].split('♠'),\ - 'A mocked link' - recommended_command = 'az {} '.format(command) - for parameter, placeholder in zip(parameters, placeholders): - recommended_command += '{} {}{}'.format(parameter, placeholder, ' ' if placeholder else '') - recommendations.append((recommended_command.strip(), description, link)) + recommendation = { + 'command': result['command'], + 'description': 'A mocked description', + 'parameters': result['parameters'].split(','), + 'placeholders': result['placeholders'].split('♠'), + 'link': 'A mocked link' + } + recommendations.append(recommendation) self.aladdin_recommendations.extend(recommendations) @@ -141,26 +138,77 @@ def provide_recommendations(self): """Provide recommendations from Aladdin service, which include both commands and reference link along with their descriptions. """ + from azure.cli.core.style import Style + + def format_raw_command(command, parameters, placeholders): + """Format the command info to get an executable command. """ + raw_command = 'az {} '.format(command) + for parameter, placeholder in zip(parameters, placeholders): + raw_command += '{} {}{}'.format(parameter, placeholder, ' ' if placeholder else '') + return raw_command.strip() + + def format_decorated_command(command, parameters, placeholders): + """Format the command info to get an decorated command. + The decorations of a command include: + 1. Use '<>' to wrap the placeholders for the parameters not specified by users + 2. Use user's input values to replace the placeholders for the parameters users have specified + 2. Apply colorization for the command + """ + placeholders = ['<{}>'.format(placeholder) for placeholder in placeholders if placeholder] + full_command = format_raw_command(command, parameters, placeholders) + # replace the placeholders with user's input values only when the recommended + # command's name is the same with user's input command name + if command == self.command: + full_command = self._replace_parameter_values(full_command) + + # get styled command + styled_command = [] + command_args = full_command.split(' ') + for index, arg in enumerate(command_args): + spaced_arg = ' {}'.format(arg) if index > 0 else arg + if index > 0 and command_args[index - 1].startswith('-') and not arg.startswith('-'): + styled_command.append((Style.PRIMARY, spaced_arg)) + else: + styled_command.append((Style.ACTION, spaced_arg)) + + return styled_command # get recommendations from Aladdin service - if not self._disable_aladdin_service(): + if not self._disable_aladdin_service() and \ + not self.cli_ctx.config.getboolean('core', 'disable_error_recommendation', False): self._set_aladdin_recommendations() - recommendations = [(item[0], item[1]) for item in self.aladdin_recommendations] - recommended_commands = [item[0] for item in recommendations] + raw_commands = [] + decorated_recommendations = [] + for recommendation in self.aladdin_recommendations: + # generate raw commands recorded in Telemetry + raw_command = format_raw_command(recommendation['command'], + recommendation['parameters'], + recommendation['placeholders']) + raw_commands.append(raw_command) + + # generate decorated commands shown to users + decorated_command = format_decorated_command(recommendation['command'], + recommendation['parameters'], + recommendation['placeholders']) + decorated_description = [(Style.SECONDARY, recommendation['description'] + '\n')] + decorated_recommendations.append((decorated_command, decorated_description)) + + # add reference link as a recommendation if self.aladdin_recommendations: - _, _, link = self.aladdin_recommendations[0] - recommendations.append((link, 'Read more about the command in reference docs')) + 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)) # set the recommend command into Telemetry - self._set_recommended_command_to_telemetry(recommended_commands) + self._set_recommended_command_to_telemetry(raw_commands) - return recommendations + return decorated_recommendations - def _set_recommended_command_to_telemetry(self, recommended_commands): # pylint: disable=no-self-use + def _set_recommended_command_to_telemetry(self, raw_commands): # pylint: disable=no-self-use """Set the recommended command to Telemetry for analysis. """ - telemetry.set_debug_info('AladdinRecommendCommand', ';'.join(recommended_commands)) + telemetry.set_debug_info('AladdinRecommendCommand', ';'.join(raw_commands)) def _disable_aladdin_service(self): """Decide whether to disable aladdin request when a command fails. @@ -354,11 +402,8 @@ def get_user_param_value(target_param, user_kwargs, param_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]) return ' '.join(command_args) From 890072f00e2a077bc172ac42103ce755b122c8bc Mon Sep 17 00:00:00 2001 From: houk-ms Date: Tue, 15 Dec 2020 14:19:04 +0800 Subject: [PATCH 03/10] sort the recommendations --- src/azure-cli-core/azure/cli/core/_help.py | 9 +- .../azure/cli/core/command_recommender.py | 106 ++++++++++++------ src/azure-cli-core/azure/cli/core/parser.py | 12 +- 3 files changed, 91 insertions(+), 36 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_help.py b/src/azure-cli-core/azure/cli/core/_help.py index ebcbfb0c2c4..6721ad19935 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), example.name) 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/command_recommender.py b/src/azure-cli-core/azure/cli/core/command_recommender.py index a1307f348b6..1c37977567d 100644 --- a/src/azure-cli-core/azure/cli/core/command_recommender.py +++ b/src/azure-cli-core/azure/cli/core/command_recommender.py @@ -57,10 +57,16 @@ def __init__(self, command, parameters, extension, error_msg, cli_ctx): self.extension = extension self.error_msg = error_msg self.cli_ctx = cli_ctx - - # item is a tuple with the form: (command, description, link) + # 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): # pylint: disable=too-many-locals + """Set recommendations from help files""" + + self.help_examples.extend(examples) + def _set_aladdin_recommendations(self): # pylint: disable=too-many-locals """Set recommendations from aladdin service. Call the aladdin service API, parse the response and set the recommendations. @@ -73,7 +79,8 @@ def _set_aladdin_recommendations(self): # pylint: disable=too-many-locals from http import HTTPStatus from azure.cli.core import __version__ as version - api_url = 'https://app.aladdin.microsoft.com/api/v1.0/suggestions' + # api_url = 'https://app.aladdin.microsoft.com/api/v1.0/suggestions' + api_url = 'https://aladdindevwestus-app.aladdindevwestus-env.p.azurewebsites.net//api/v1/suggestions' correlation_id = telemetry._session.correlation_id # pylint: disable=protected-access subscription_id = telemetry._get_azure_subscription_id() # pylint: disable=protected-access # Used for DDOS protection and rate limiting @@ -122,13 +129,16 @@ def _set_aladdin_recommendations(self): # pylint: disable=too-many-locals recommendations = [] if response and response.status_code == HTTPStatus.OK: for result in response.json(): - # parse the response and format the recommendation + # 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': result['command'], - 'description': 'A mocked description', - 'parameters': result['parameters'].split(','), - 'placeholders': result['placeholders'].split('♠'), - 'link': 'A mocked link' + 'command': raw_command.strip(), + 'description': result['description'], + 'link': result['link'] } recommendations.append(recommendation) @@ -140,30 +150,53 @@ def provide_recommendations(self): """ from azure.cli.core.style import Style - def format_raw_command(command, parameters, placeholders): - """Format the command info to get an executable command. """ - raw_command = 'az {} '.format(command) - for parameter, placeholder in zip(parameters, placeholders): - raw_command += '{} {}{}'.format(parameter, placeholder, ' ' if placeholder else '') - return raw_command.strip() + def sort_recommendations(recommendations): + """Sort the recommendations by parameter matching. + The sorting rules below are applied in oder: + 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 + """ + + candidates = [] + target_arg_list = self._normalize_parameters(self.parameters) + for recommendation in recommendations: + matches = 0 + arg_list = self._normalize_parameters(recommendation['command'].split(' ')) + + # ignore those not starting 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 - def format_decorated_command(command, parameters, placeholders): + candidates.append({ + '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['arg_list'])), reverse=True) + + return [candidate['recommendation'] for candidate in candidates] + + def decorate_command(raw_command): """Format the command info to get an decorated command. The decorations of a command include: - 1. Use '<>' to wrap the placeholders for the parameters not specified by users - 2. Use user's input values to replace the placeholders for the parameters users have specified + 1. Use user's input values to replace the placeholders for the parameters users have specified 2. Apply colorization for the command """ - placeholders = ['<{}>'.format(placeholder) for placeholder in placeholders if placeholder] - full_command = format_raw_command(command, parameters, placeholders) # replace the placeholders with user's input values only when the recommended # command's name is the same with user's input command name - if command == self.command: - full_command = self._replace_parameter_values(full_command) + if raw_command.startswith('az {}'.format(self.command)): + raw_command = self._replace_parameter_values(raw_command) - # get styled command + # command colorization styled_command = [] - command_args = full_command.split(' ') + command_args = raw_command.split(' ') for index, arg in enumerate(command_args): spaced_arg = ' {}'.format(arg) if index > 0 else arg if index > 0 and command_args[index - 1].startswith('-') and not arg.startswith('-'): @@ -178,27 +211,34 @@ def format_decorated_command(command, parameters, placeholders): not self.cli_ctx.config.getboolean('core', 'disable_error_recommendation', False): self._set_aladdin_recommendations() + # recommendations are either from Aladdin or help examples + recommendations = self.aladdin_recommendations + if not recommendations: + recommendations = self.help_examples + + # order the recommendations by parameter matching, get the top 3 recommended commands + recommendations = sort_recommendations(recommendations)[:3] + raw_commands = [] decorated_recommendations = [] - for recommendation in self.aladdin_recommendations: + for recommendation in recommendations: # generate raw commands recorded in Telemetry - raw_command = format_raw_command(recommendation['command'], - recommendation['parameters'], - recommendation['placeholders']) + raw_command = recommendation['command'] raw_commands.append(raw_command) # generate decorated commands shown to users - decorated_command = format_decorated_command(recommendation['command'], - recommendation['parameters'], - recommendation['placeholders']) + decorated_command = decorate_command(raw_command) decorated_description = [(Style.SECONDARY, recommendation['description'] + '\n')] decorated_recommendations.append((decorated_command, decorated_description)) # add reference link as a recommendation + from azure.cli.core.parser import OVERVIEW_REFERENCE + 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)) + + decorated_description = [(Style.SECONDARY, 'Read more about the command in reference docs')] + decorated_recommendations.append((decorated_link, decorated_description)) # set the recommend command into Telemetry self._set_recommended_command_to_telemetry(raw_commands) diff --git a/src/azure-cli-core/azure/cli/core/parser.py b/src/azure-cli-core/azure/cli/core/parser.py index aa0da535cb5..f70711f5dec 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): @@ -161,6 +160,7 @@ def error(self, message): command_arguments = self._get_failure_recovery_arguments() 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)) recommendations = recommender.provide_recommendations() az_error = ArgumentUsageError(message) @@ -372,9 +372,16 @@ def _check_value(self, action, value): # pylint: disable=too-many-statements, t cli_ctx = self.cli_ctx or (self.cli_help.cli_ctx if self.cli_help else None) caused_by_extension_not_installed = False + command_name_inferred = self.prog error_msg = None if not self.command_source: candidates = difflib.get_close_matches(value, action.choices, cutoff=0.7) + if candidates: + # use the most likely candidate to replace the misspelled command + args = self.prog.split() + self._raw_arguments + args_inferred = [item if item != value else candidates[0] for item in args] + command_name_inferred = ' '.join(args_inferred).split('-')[0] + use_dynamic_install = self._get_extension_use_dynamic_install_config() if use_dynamic_install != 'no' and not candidates: # Check if the command is from an extension @@ -449,6 +456,7 @@ def _check_value(self, action, value): # pylint: disable=too-many-statements, t # recommend a command for user recommender = CommandRecommender(*command_arguments, error_msg, cli_ctx) + recommender.set_help_examples(self.get_examples(command_name_inferred)) recommendations = recommender.provide_recommendations() if recommendations: az_error.set_aladdin_recommendation(recommendations) From 7f3aad183e9ba82e3a5f58e90633bac70461e290 Mon Sep 17 00:00:00 2001 From: houk-ms Date: Tue, 15 Dec 2020 15:54:21 +0800 Subject: [PATCH 04/10] support config to disable error recommendations --- .../azure/cli/core/command_recommender.py | 22 +++++++++++-------- src/azure-cli-core/azure/cli/core/parser.py | 2 +- 2 files changed, 14 insertions(+), 10 deletions(-) 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 1c37977567d..b03db105b7a 100644 --- a/src/azure-cli-core/azure/cli/core/command_recommender.py +++ b/src/azure-cli-core/azure/cli/core/command_recommender.py @@ -62,7 +62,7 @@ def __init__(self, command, parameters, extension, error_msg, cli_ctx): # the item is a dict with the form {'command': #, 'description': #, 'link': #} self.aladdin_recommendations = [] - def set_help_examples(self, examples): # pylint: disable=too-many-locals + def set_help_examples(self, examples): """Set recommendations from help files""" self.help_examples.extend(examples) @@ -80,6 +80,7 @@ def _set_aladdin_recommendations(self): # pylint: disable=too-many-locals from azure.cli.core import __version__ as version # api_url = 'https://app.aladdin.microsoft.com/api/v1.0/suggestions' + # test endpoint, need to be replaced api_url = 'https://aladdindevwestus-app.aladdindevwestus-env.p.azurewebsites.net//api/v1/suggestions' correlation_id = telemetry._session.correlation_id # pylint: disable=protected-access subscription_id = telemetry._get_azure_subscription_id() # pylint: disable=protected-access @@ -145,14 +146,14 @@ def _set_aladdin_recommendations(self): # pylint: disable=too-many-locals self.aladdin_recommendations.extend(recommendations) def provide_recommendations(self): - """Provide recommendations from Aladdin service, - which include both commands and reference link along with their descriptions. + """Provide recommendations either from Aladdin service or CLI help examples, + which include both commands and reference links along with their descriptions. """ from azure.cli.core.style import Style def sort_recommendations(recommendations): """Sort the recommendations by parameter matching. - The sorting rules below are applied in oder: + 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 @@ -164,7 +165,7 @@ def sort_recommendations(recommendations): matches = 0 arg_list = self._normalize_parameters(recommendation['command'].split(' ')) - # ignore those not starting with the use's input command name + # 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: @@ -206,17 +207,20 @@ def decorate_command(raw_command): return styled_command + # do not recommend commands if it is disabled by config + if self.cli_ctx and self.cli_ctx.config.getboolean('core', 'disable_error_recommendation', False): + return [] + # get recommendations from Aladdin service - if not self._disable_aladdin_service() and \ - not self.cli_ctx.config.getboolean('core', 'disable_error_recommendation', False): + if not self._disable_aladdin_service(): self._set_aladdin_recommendations() - # recommendations are either from Aladdin or help examples + # recommendations are either all from Aladdin or all from help examples recommendations = self.aladdin_recommendations if not recommendations: recommendations = self.help_examples - # order the recommendations by parameter matching, get the top 3 recommended commands + # sort the recommendations by parameter matching, get the top 3 recommended commands recommendations = sort_recommendations(recommendations)[:3] raw_commands = [] diff --git a/src/azure-cli-core/azure/cli/core/parser.py b/src/azure-cli-core/azure/cli/core/parser.py index f70711f5dec..dcf224afdb6 100644 --- a/src/azure-cli-core/azure/cli/core/parser.py +++ b/src/azure-cli-core/azure/cli/core/parser.py @@ -380,7 +380,7 @@ def _check_value(self, action, value): # pylint: disable=too-many-statements, t # use the most likely candidate to replace the misspelled command args = self.prog.split() + self._raw_arguments args_inferred = [item if item != value else candidates[0] for item in args] - command_name_inferred = ' '.join(args_inferred).split('-')[0] + command_name_inferred = (' '.join(args_inferred).split('-')[0]).strip() use_dynamic_install = self._get_extension_use_dynamic_install_config() if use_dynamic_install != 'no' and not candidates: From 98595c9ad369210d5bb1902810d2ef462705a0f8 Mon Sep 17 00:00:00 2001 From: houk-ms Date: Fri, 18 Dec 2020 18:19:58 +0800 Subject: [PATCH 05/10] command recommender refactoring --- .../azure/cli/core/command_recommender.py | 436 ++++++++++-------- src/azure-cli-core/azure/cli/core/style.py | 19 + .../core/tests/test_command_recommender.py | 110 +++++ .../azure/cli/core/tests/test_style.py | 36 ++ 4 files changed, 413 insertions(+), 188 deletions(-) create mode 100644 src/azure-cli-core/azure/cli/core/tests/test_command_recommender.py 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 b03db105b7a..4c8d4a265cc 100644 --- a/src/azure-cli-core/azure/cli/core/command_recommender.py +++ b/src/azure-cli-core/azure/cli/core/command_recommender.py @@ -35,6 +35,54 @@ class AladdinUserFaultType(Enum): InvalidAccountName = 'InvalidAccountName' +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.""" @@ -63,13 +111,17 @@ def __init__(self, command, parameters, extension, error_msg, cli_ctx): 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): # pylint: disable=too-many-locals - """Set recommendations from aladdin service. - Call the aladdin service API, parse the response and set the recommendations. + """Set Aladdin recommendations. + Call the API, parse the response and set aladdin_recommendations. """ import hashlib @@ -79,9 +131,7 @@ def _set_aladdin_recommendations(self): # pylint: disable=too-many-locals from http import HTTPStatus from azure.cli.core import __version__ as version - # api_url = 'https://app.aladdin.microsoft.com/api/v1.0/suggestions' - # test endpoint, need to be replaced - api_url = 'https://aladdindevwestus-app.aladdindevwestus-env.p.azurewebsites.net//api/v1/suggestions' + api_url = 'https://app.aladdin.microsoft.com/api/v1.0/suggestions' correlation_id = telemetry._session.correlation_id # pylint: disable=protected-access subscription_id = telemetry._get_azure_subscription_id() # pylint: disable=protected-access # Used for DDOS protection and rate limiting @@ -94,7 +144,7 @@ def _set_aladdin_recommendations(self): # pylint: disable=too-many-locals } context = { 'versionNumber': version, - 'errorType': self._get_error_type() + 'errorType': get_error_type(self.error_msg) } if telemetry.is_telemetry_enabled(): @@ -146,17 +196,30 @@ def _set_aladdin_recommendations(self): # pylint: disable=too-many-locals self.aladdin_recommendations.extend(recommendations) def provide_recommendations(self): - """Provide recommendations either from Aladdin service or CLI help examples, + """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 """ - from azure.cli.core.style import Style + + from azure.cli.core.style import Style, get_styled_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 = [] @@ -184,28 +247,24 @@ def sort_recommendations(recommendations): return [candidate['recommendation'] for candidate in candidates] - def decorate_command(raw_command): - """Format the command info to get an decorated command. - The decorations of a command include: - 1. Use user's input values to replace the placeholders for the parameters users have specified - 2. Apply colorization for the command + def replace_param_values(command): + """Replace the parameter values in a command with user's input values + + :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 placeholders with user's input values only when the recommended + + # replace the parameter values only when the recommended # command's name is the same with user's input command name - if raw_command.startswith('az {}'.format(self.command)): - raw_command = self._replace_parameter_values(raw_command) - - # command colorization - styled_command = [] - command_args = raw_command.split(' ') - for index, arg in enumerate(command_args): - spaced_arg = ' {}'.format(arg) if index > 0 else arg - if index > 0 and command_args[index - 1].startswith('-') and not arg.startswith('-'): - styled_command.append((Style.PRIMARY, spaced_arg)) - else: - styled_command.append((Style.ACTION, spaced_arg)) + if not command.startswith('az {}'.format(self.command)): + return command + + source_kwargs = get_parameter_kwargs(self.parameters) + param_mappings = self._get_param_mappings() - return styled_command + 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.getboolean('core', 'disable_error_recommendation', False): @@ -230,13 +289,15 @@ def decorate_command(raw_command): 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 = decorate_command(raw_command) + decorated_command = get_styled_command(raw_command) decorated_description = [(Style.SECONDARY, recommendation['description'] + '\n')] decorated_recommendations.append((decorated_command, decorated_description)) # add reference link as a recommendation - from azure.cli.core.parser import OVERVIEW_REFERENCE decorated_link = [(Style.HYPERLINK, OVERVIEW_REFERENCE)] if self.aladdin_recommendations: decorated_link = [(Style.HYPERLINK, self.aladdin_recommendations[0]['link'])] @@ -249,17 +310,33 @@ def decorate_command(raw_command): return decorated_recommendations - def _set_recommended_command_to_telemetry(self, raw_commands): # pylint: disable=no-self-use - """Set the recommended command to Telemetry for analysis. """ + def _set_recommended_command_to_telemetry(self, raw_commands): + """Set the recommended commands to Telemetry - telemetry.set_debug_info('AladdinRecommendCommand', ';'.join(raw_commands)) + 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 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 @@ -276,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 @@ -338,116 +383,131 @@ 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 + } + + 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] - - parameter_kwargs[param_name] = param_val - - return parameter_kwargs - - 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] + for param, val in source_kwargs.items(): + if param in param_mappings: + standard_param = param_mappings[param] + standard_source_kwargs[standard_param] = val - return None + 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] - user_kwargs = get_parameter_kwargs(self.parameters) - param_mappings = self._get_parameter_mappings() + return None - 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 + 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/style.py b/src/azure-cli-core/azure/cli/core/style.py index 4369ed84e8b..dbcc95066a3 100644 --- a/src/azure-cli-core/azure/cli/core/style.py +++ b/src/azure-cli-core/azure/cli/core/style.py @@ -72,3 +72,22 @@ def format_styled_text(styled_text): # Reset control sequence formatted_parts.append(Fore.RESET) return ''.join(formatted_parts) + + +def get_styled_command(raw_command): + styled_command = [] + argument_begins = False + + for index, arg in enumerate(raw_command.split()): + spaced_arg = ' {}'.format(arg) if index > 0 else arg + style = Style.PRIMARY + + if arg.startswith('-') and '=' not in arg: + style = Style.ACTION + argument_begins = True + elif not argument_begins and '=' not in arg: + style = Style.ACTION + + styled_command.append((style, spaced_arg)) + + return styled_command 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_style.py b/src/azure-cli-core/azure/cli/core/tests/test_style.py index 37bbb97be00..31147dd02f9 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_style.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_style.py @@ -43,6 +43,42 @@ def test_format_styled_text(self): with self.assertRaisesRegex(CLIInternalError, "Invalid styled text."): format_styled_text(["dummy text"]) + def test_get_styled_text(self): + from azure.cli.core.style import Style, get_styled_command, format_styled_text + + raw_commands = [ + 'az cmd', + 'az cmd sub-cmd', + 'az cmd --arg val', + 'az cmd --arg=val', + 'az cmd --arg "content with space"', + 'az cmd --arg val1 val2', + 'az cmd sub-cmd --arg1 val1 --arg2=" " --arg3 "val2 val3" --arg4 val4' + ] + + expected = [ + [(Style.ACTION, 'az'), (Style.ACTION, ' cmd')], + [(Style.ACTION, 'az'), (Style.ACTION, ' cmd'), (Style.ACTION, ' sub-cmd')], + [(Style.ACTION, 'az'), (Style.ACTION, ' cmd'), (Style.ACTION, ' --arg'), (Style.PRIMARY, ' val')], + [(Style.ACTION, 'az'), (Style.ACTION, ' cmd'), (Style.PRIMARY, ' --arg=val')], + [(Style.ACTION, 'az'), (Style.ACTION, ' cmd'), (Style.ACTION, ' --arg'), (Style.PRIMARY, ' "content'), + (Style.PRIMARY, ' with'), (Style.PRIMARY, ' space"')], + [(Style.ACTION, 'az'), (Style.ACTION, ' cmd'), (Style.ACTION, ' --arg'), + (Style.PRIMARY, ' val1'), (Style.PRIMARY, ' val2')], + [(Style.ACTION, 'az'), (Style.ACTION, ' cmd'), (Style.ACTION, ' sub-cmd'), (Style.ACTION, ' --arg1'), + (Style.PRIMARY, ' val1'), (Style.PRIMARY, ' --arg2="'), (Style.PRIMARY, ' "'), (Style.ACTION, ' --arg3'), + (Style.PRIMARY, ' "val2'), (Style.PRIMARY, ' val3"'), (Style.ACTION, ' --arg4'), (Style.PRIMARY, ' val4')] + ] + + for cmd_ind, command in enumerate(raw_commands): + styled_command = get_styled_command(command) + expected_style = expected[cmd_ind] + + for tpl_ind, style_tuple in enumerate(styled_command): + expected_tuple = expected_style[tpl_ind] + self.assertEqual(style_tuple[0], expected_tuple[0]) + self.assertEqual(style_tuple[1], expected_tuple[1]) + if __name__ == '__main__': unittest.main() From 75ae6004c581f45da2115f600e0185d80624fe69 Mon Sep 17 00:00:00 2001 From: houk-ms Date: Mon, 21 Dec 2020 10:47:21 +0800 Subject: [PATCH 06/10] code style refining --- src/azure-cli-core/azure/cli/core/command_recommender.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 4c8d4a265cc..8844474f6e5 100644 --- a/src/azure-cli-core/azure/cli/core/command_recommender.py +++ b/src/azure-cli-core/azure/cli/core/command_recommender.py @@ -247,7 +247,7 @@ def sort_recommendations(recommendations): return [candidate['recommendation'] for candidate in candidates] - def replace_param_values(command): + def replace_param_values(command): # pylint: disable=unused-variable """Replace the parameter values in a command with user's input values :param command: The command whose parameter value needs to be replaced From 3e588913ff1295f6df4bf8fc8f63f712a7d19372 Mon Sep 17 00:00:00 2001 From: houk-ms Date: Wed, 20 Jan 2021 16:56:07 +0800 Subject: [PATCH 07/10] code refining --- src/azure-cli-core/azure/cli/core/command_recommender.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 771ab703026..aaf75e76c76 100644 --- a/src/azure-cli-core/azure/cli/core/command_recommender.py +++ b/src/azure-cli-core/azure/cli/core/command_recommender.py @@ -417,7 +417,9 @@ def get_parameter_mappings(command_table): '--output': None, '--query': None, '--debug': None, - '--verbose': None + '--verbose': None, + '--yes': None, + '--no-wait': None } if parameter_table: From 62e85fd82042a37cc219b2b49227174418fb002a Mon Sep 17 00:00:00 2001 From: houk-ms Date: Wed, 20 Jan 2021 17:11:49 +0800 Subject: [PATCH 08/10] resolve merge conflicts --- src/azure-cli-core/azure/cli/core/tests/test_style.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/tests/test_style.py b/src/azure-cli-core/azure/cli/core/tests/test_style.py index 03ff285556f..68ac9fe1561 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_style.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_style.py @@ -68,13 +68,8 @@ def test_format_styled_text_on_error(self): with self.assertRaisesRegex(CLIInternalError, "Invalid styled text."): format_styled_text(["dummy text"]) -<<<<<<< HEAD - def test_get_styled_text(self): - from azure.cli.core.style import Style, get_styled_command, format_styled_text -======= def test_highlight_command(self): from azure.cli.core.style import Style, highlight_command, format_styled_text ->>>>>>> dev raw_commands = [ 'az cmd', From 0680f5028d8aed5c74f102f42f0cbaec181e310b Mon Sep 17 00:00:00 2001 From: houk-ms Date: Tue, 2 Feb 2021 13:27:50 +0800 Subject: [PATCH 09/10] use core.error_recommendation=on/off as error recommmendation switch --- src/azure-cli-core/azure/cli/core/command_recommender.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 aaf75e76c76..a302abbc604 100644 --- a/src/azure-cli-core/azure/cli/core/command_recommender.py +++ b/src/azure-cli-core/azure/cli/core/command_recommender.py @@ -267,7 +267,7 @@ def replace_param_values(command): # pylint: disable=unused-variable 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.getboolean('core', 'disable_error_recommendation', False): + if self.cli_ctx and self.cli_ctx.config.get('core', 'error_recommendation', 'on').upper() == 'OFF': return [] # get recommendations from Aladdin service From 6db67e7611629d661dcfb9dfe5524fb203063920 Mon Sep 17 00:00:00 2001 From: houk-ms Date: Tue, 2 Feb 2021 14:22:02 +0800 Subject: [PATCH 10/10] resolve conflicts --- src/azure-cli-core/azure/cli/core/parser.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/parser.py b/src/azure-cli-core/azure/cli/core/parser.py index 9a195f9d938..f8933924de1 100644 --- a/src/azure-cli-core/azure/cli/core/parser.py +++ b/src/azure-cli-core/azure/cli/core/parser.py @@ -505,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) \ @@ -515,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()