diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index de95fdd2b38..54a10128d86 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -120,4 +120,6 @@ /src/kusto/ @ilayr @orhasban @astauben +/src/ai-did-you-mean-this/ @christopher-o-toole + /src/custom-providers/ @jsntcy diff --git a/src/ai-did-you-mean-this/HISTORY.rst b/src/ai-did-you-mean-this/HISTORY.rst new file mode 100644 index 00000000000..0162192acb9 --- /dev/null +++ b/src/ai-did-you-mean-this/HISTORY.rst @@ -0,0 +1,10 @@ +.. :changelog: + +Release History +=============== + +0.1.0 +++++++ +* Initial release. +* Add autogenerated recommendations for recovery from UserFault failures. +* Ensure that the hook is activated in common UserFault failure scenarios. \ No newline at end of file diff --git a/src/ai-did-you-mean-this/README.md b/src/ai-did-you-mean-this/README.md new file mode 100644 index 00000000000..634db4ef845 --- /dev/null +++ b/src/ai-did-you-mean-this/README.md @@ -0,0 +1,91 @@ +# Microsoft Azure CLI 'AI Did You Mean This' Extension # + +### Installation ### +To install the extension, use the below CLI command +``` +az extension add --name ai-did-you-mean-this +``` + +### Background ### +The purpose of this extension is help users recover from failure. Once installed, failure recovery recommendations will automatically be provided for supported command failures. In cases where no recommendations are available, a prompt to use `az find` will be shown provided that the command can be matched to a valid CLI command. +### Limitations ### +For now, recommendations are limited to parser failures (i.e. not in a command group, argument required, unrecognized parameter, expected one argument, etc). Support for more core failures is planned for a future release. +### Try it out! ### +The following examples demonstrate how to trigger the extension. For a more complete list of supported CLI failure types, see this [CLI PR](https://github.com/Azure/azure-cli/pull/12889). Keep in mind that the recommendations shown here may be different from the ones that you receive. + +#### az account #### +``` +> az account +az account: error: the following arguments are required: _subcommand +usage: az account [-h] + {list,set,show,clear,list-locations,get-access-token,lock,management-group} + ... + +Here are the most common ways users succeeded after [account] failed: + az account list + az account show +``` + +#### az account set #### +``` +> az account set +az account set: error: the following arguments are required: --subscription/-s +usage: az account set [-h] [--verbose] [--debug] [--only-show-errors] + [--output {json,jsonc,yaml,yamlc,table,tsv,none}] + [--query JMESPATH] --subscription SUBSCRIPTION + +Here are the most common ways users succeeded after [account set] failed: + az account set --subscription Subscription +``` + +#### az group create #### +``` +>az group create +az group create: error: the following arguments are required: --name/--resource-group/-n/-g, --location/-l +usage: az group create [-h] [--verbose] [--debug] [--only-show-errors] + [--output {json,jsonc,yaml,yamlc,table,tsv,none}] + [--query JMESPATH] [--subscription _SUBSCRIPTION] + --name RG_NAME --location LOCATION + [--tags [TAGS [TAGS ...]]] [--managed-by MANAGED_BY] + +Here are the most common ways users succeeded after [group create] failed: + az group create --location westeurope --resource-group MyResourceGroup +``` +#### az vm list ### +``` +> az vm list --query ".id" +az vm list: error: argument --query: invalid jmespath_type value: '.id' +usage: az vm list [-h] [--verbose] [--debug] [--only-show-errors] + [--output {json,jsonc,yaml,yamlc,table,tsv,none}] + [--query JMESPATH] [--subscription _SUBSCRIPTION] + [--resource-group RESOURCE_GROUP_NAME] [--show-details] + +Sorry I am not able to help with [vm list] +Try running [az find "az vm list"] to see examples of [vm list] from other users. +``` +### Developer Build ### +If you want to try an experimental release of the extension, it is recommended you do so in a [Docker container](https://www.docker.com/resources/what-container). Keep in mind that you'll need to install Docker and pull the desired [Azure CLI image](https://hub.docker.com/_/microsoft-azure-cli) from the Microsoft Container Registry before proceeding. + +#### Setting up your Docker Image #### +To run the Azure CLI Docker image as an interactive shell, run the below command by replacing `` with your desired CLI version +```bash +docker run -it mcr.microsoft.com/azure-cli: +export EXT="ai-did-you-mean-this" +pip install --upgrade --target ~/.azure/cliextensions/$EXT "git+https://github.com/christopher-o-toole/azure-cli-extensions.git@thoth-extension#subdirectory=src/$EXT&egg=$EXT" +``` +Each time you start a new shell, you'll need to login before you can start using the extension. To do so, run +```bash +az login +``` +and follow the instructions given in the prompt. Once you're logged in, try out the extension by issuing a faulty command +``` +> az account +az account: error: the following arguments are required: _subcommand +usage: az account [-h] + {list,set,show,clear,list-locations,get-access-token,lock,management-group} + ... + +Here are the most common ways users succeeded after [account] failed: + az account list + az account show +``` \ No newline at end of file diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/__init__.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/__init__.py new file mode 100644 index 00000000000..8db2b74fe04 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/__init__.py @@ -0,0 +1,52 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azure.cli.core import AzCommandsLoader + +from knack.events import ( + EVENT_INVOKER_CMD_TBL_LOADED +) + +from azext_ai_did_you_mean_this._help import helps # pylint: disable=unused-import +from azext_ai_did_you_mean_this._cmd_table import on_command_table_loaded + + +def inject_functions_into_core(): + from azure.cli.core.parser import AzCliCommandParser + from azext_ai_did_you_mean_this.custom import recommend_recovery_options + AzCliCommandParser.recommendation_provider = recommend_recovery_options + + +# pylint: disable=too-few-public-methods +class GlobalConfig(): + ENABLE_STYLING = False + + +class AiDidYouMeanThisCommandsLoader(AzCommandsLoader): + def __init__(self, cli_ctx=None): + from azure.cli.core.commands import CliCommandType + + ai_did_you_mean_this_custom = CliCommandType( + operations_tmpl='azext_ai_did_you_mean_this.custom#{}') + super().__init__(cli_ctx=cli_ctx, + custom_command_type=ai_did_you_mean_this_custom) + self.cli_ctx.register_event(EVENT_INVOKER_CMD_TBL_LOADED, on_command_table_loaded) + inject_functions_into_core() + # per https://github.com/Azure/azure-cli/pull/12601 + try: + GlobalConfig.ENABLE_STYLING = cli_ctx.enable_color + except AttributeError: + pass + + def load_command_table(self, args): + from azext_ai_did_you_mean_this.commands import load_command_table + load_command_table(self, args) + return self.command_table + + def load_arguments(self, command): + pass + + +COMMAND_LOADER_CLS = AiDidYouMeanThisCommandsLoader diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_cmd_table.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_cmd_table.py new file mode 100644 index 00000000000..9d77a5db9bd --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_cmd_table.py @@ -0,0 +1,13 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + + +class CommandTable(): # pylint: disable=too-few-public-methods + CMD_TBL = None + + +def on_command_table_loaded(_, **kwargs): + cmd_tbl = kwargs.pop('cmd_tbl', None) + CommandTable.CMD_TBL = cmd_tbl diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_const.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_const.py new file mode 100644 index 00000000000..238ec3151e6 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_const.py @@ -0,0 +1,34 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +UPDATE_RECOMMENDATION_STR = ( + "Better failure recovery recommendations are available from the latest version of the CLI. " + "Please update for the best experience.\n" +) + +UNABLE_TO_HELP_FMT_STR = ( + '\nSorry I am not able to help with [{command}]' + '\nTry running [az find "az {command}"] to see examples of [{command}] from other users.' +) + +RECOMMENDATION_HEADER_FMT_STR = ( + '\nHere are the most common ways users succeeded after [{command}] failed:' +) + +TELEMETRY_MUST_BE_ENABLED_STR = ( + 'User must agree to telemetry before failure recovery recommendations can be presented.' +) + +TELEMETRY_MISSING_SUBSCRIPTION_ID_STR = ( + "Subscription ID was not set in telemetry." +) + +TELEMETRY_MISSING_CORRELATION_ID_STR = ( + "Correlation ID was not set in telemetry." +) + +UNABLE_TO_CALL_SERVICE_STR = ( + 'Either the subscription ID or correlation ID was not set. Aborting operation.' +) diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_help.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_help.py new file mode 100644 index 00000000000..8c8e70d9a85 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_help.py @@ -0,0 +1,17 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from knack.help_files import helps # pylint: disable=unused-import + +helps['ai-did-you-mean-this'] = """ + type: group + short-summary: Add recommendations for recovering from failure. +""" + +helps['ai-did-you-mean-this version'] = """ + type: command + short-summary: Prints the extension version. +""" diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_style.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_style.py new file mode 100644 index 00000000000..f32e8452062 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_style.py @@ -0,0 +1,28 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import sys + +from azext_ai_did_you_mean_this import GlobalConfig + + +def style_message(msg): + if should_enable_styling(): + import colorama # pylint: disable=import-error + + try: + msg = colorama.Style.BRIGHT + msg + colorama.Style.RESET_ALL + except KeyError: + pass + return msg + + +def should_enable_styling(): + try: + if GlobalConfig.ENABLE_STYLING and sys.stdout.isatty(): + return True + except AttributeError: + pass + return False diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/azext_metadata.json b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/azext_metadata.json new file mode 100644 index 00000000000..6a44beb25b4 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/azext_metadata.json @@ -0,0 +1,4 @@ +{ + "azext.isPreview": true, + "azext.minCliCoreVersion": "2.4.0" +} \ No newline at end of file diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/commands.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/commands.py new file mode 100644 index 00000000000..0732b3eba2c --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/commands.py @@ -0,0 +1,12 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# pylint: disable=line-too-long + + +def load_command_table(self, _): + + with self.command_group('ai-did-you-mean-this', is_preview=True) as g: + g.custom_command('version', 'show_extension_version') diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/custom.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/custom.py new file mode 100644 index 00000000000..ede97b1c7e4 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/custom.py @@ -0,0 +1,242 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import json +from http import HTTPStatus + +import requests +from requests import RequestException + +import azure.cli.core.telemetry as telemetry + +from knack.log import get_logger +from knack.util import CLIError # pylint: disable=unused-import + +from azext_ai_did_you_mean_this.failure_recovery_recommendation import FailureRecoveryRecommendation +from azext_ai_did_you_mean_this._style import style_message +from azext_ai_did_you_mean_this._const import ( + RECOMMENDATION_HEADER_FMT_STR, + UNABLE_TO_HELP_FMT_STR, + TELEMETRY_MUST_BE_ENABLED_STR, + TELEMETRY_MISSING_SUBSCRIPTION_ID_STR, + TELEMETRY_MISSING_CORRELATION_ID_STR, + UNABLE_TO_CALL_SERVICE_STR +) +from azext_ai_did_you_mean_this._cmd_table import CommandTable + +logger = get_logger(__name__) + + +# Commands +# note: at least one command is required in order for the CLI to load the extension. +def show_extension_version(): + print(f'Current version: 0.1.0') + + +def _log_debug(msg, *args, **kwargs): + # TODO: see if there's a way to change the log formatter locally without printing to stdout + msg = f'[Thoth]: {msg}' + logger.debug(msg, *args, **kwargs) + + +def get_parameter_table(cmd_table, command, recurse=True): + az_cli_command = cmd_table.get(command, None) + parameter_table = az_cli_command.arguments if az_cli_command else None + + # if the specified command was not found and recursive search is enabled... + if not az_cli_command and recurse: + # if there are at least two tokens separated by whitespace, remove the last token + last_delim_idx = command.rfind(' ') + if last_delim_idx != -1: + _log_debug('Removing unknown token "%s" from command.', command[last_delim_idx + 1:]) + # try to find the truncated command. + parameter_table, command = get_parameter_table(cmd_table, command[:last_delim_idx], recurse=False) + + return parameter_table, command + + +def normalize_and_sort_parameters(cmd_table, command, parameters): + from knack.deprecation import Deprecated + _log_debug('normalize_and_sort_parameters: command: "%s", parameters: "%s"', command, parameters) + + parameter_set = set() + parameter_table, command = get_parameter_table(cmd_table, command) + + if parameters: + # TODO: Avoid setting rules for global parameters manually. + rules = { + '-h': '--help', + '-o': '--output', + '--only-show-errors': None, + '--help': None, + '--output': None, + '--query': None, + '--debug': None, + '--verbose': None + } + + blocklisted = {'--debug', '--verbose'} + + if parameter_table: + for argument in parameter_table.values(): + options = argument.type.settings['options_list'] + # remove deprecated arguments. + options = (option for option in options if not isinstance(option, Deprecated)) + + # attempt to create a rule for each potential parameter. + try: + # sort parameters by decreasing length. + sorted_options = sorted(options, key=len, reverse=True) + # select the longest parameter as the standard form + standard_form = sorted_options[0] + + for option in sorted_options[1:]: + rules[option] = standard_form + + # don't apply any rules for the parameter's standard form. + rules[standard_form] = None + except TypeError: + # ignore cases in which one of the option objects is of an unsupported type. + _log_debug('Unexpected argument options `%s` of type `%s`.', options, type(options).__name__) + + for parameter in parameters: + if parameter in rules: + # normalize the parameter or do nothing if already normalized + normalized_form = rules.get(parameter, None) or parameter + # add the parameter to our result set + parameter_set.add(normalized_form) + else: + # ignore any parameters that we were unable to validate. + _log_debug('"%s" is an invalid parameter for command "%s".', parameter, command) + + # remove any special global parameters that would typically be removed by the CLI + parameter_set.difference_update(blocklisted) + + # get the list of parameters as a comma-separated list + return command, ','.join(sorted(parameter_set)) + + +def recommend_recovery_options(version, command, parameters, extension): + from timeit import default_timer as timer + start_time = timer() + elapsed_time = None + + result = [] + cmd_tbl = CommandTable.CMD_TBL + _log_debug('recommend_recovery_options: version: "%s", command: "%s", parameters: "%s", extension: "%s"', + version, command, parameters, extension) + + # if the user doesn't agree to telemetry... + if not telemetry.is_telemetry_enabled(): + _log_debug(TELEMETRY_MUST_BE_ENABLED_STR) + return result + + # if the command is empty... + if not command: + # try to get the raw command field from telemetry. + session = telemetry._session # pylint: disable=protected-access + # get the raw command parsed by the CommandInvoker object. + command = session.raw_command + if command: + _log_debug(f'Setting command to [{command}] from telemtry.') + + def append(line): + result.append(line) + + def unable_to_help(command): + msg = UNABLE_TO_HELP_FMT_STR.format(command=command) + append(msg) + + def show_recommendation_header(command): + msg = RECOMMENDATION_HEADER_FMT_STR.format(command=command) + append(style_message(msg)) + + if extension: + _log_debug('Detected extension. No action to perform.') + if not command: + _log_debug('Command is empty. No action to perform.') + + # if an extension is in-use or the command is empty... + if extension or not command: + return result + + # perform some rudimentary parsing to extract the parameters and command in a standard form + command, parameters = normalize_and_sort_parameters(cmd_tbl, command, parameters) + response = call_aladdin_service(command, parameters, version) + + # only show recommendations when we can contact the service. + if response and response.status_code == HTTPStatus.OK: + recommendations = get_recommendations_from_http_response(response) + + if recommendations: + show_recommendation_header(command) + + for recommendation in recommendations: + append(f"\t{recommendation}") + # only prompt user to use "az find" for valid CLI commands + # note: pylint has trouble resolving statically initialized variables, which is why + # we need to disable the unsupported membership test rule + elif any(cmd.startswith(command) for cmd in cmd_tbl.keys()): # pylint: disable=unsupported-membership-test + unable_to_help(command) + + elapsed_time = timer() - start_time + _log_debug('The overall time it took to process failure recovery recommendations was %.2fms.', elapsed_time * 1000) + + return result + + +def get_recommendations_from_http_response(response): + recommendations = [] + + for suggestion in response.json(): + recommendations.append(FailureRecoveryRecommendation(suggestion)) + + return recommendations + + +def call_aladdin_service(command, parameters, version): + _log_debug('call_aladdin_service: version: "%s", command: "%s", parameters: "%s"', + version, command, parameters) + + response = None + + correlation_id = telemetry._session.correlation_id # pylint: disable=protected-access + subscription_id = telemetry._get_azure_subscription_id() # pylint: disable=protected-access + + if subscription_id and correlation_id: + context = { + "sessionId": correlation_id, + "subscriptionId": subscription_id, + "versionNumber": version + } + + query = { + "command": command, + "parameters": parameters + } + + api_url = 'https://app.aladdindev.microsoft.com/api/v1.0/suggestions' + headers = {'Content-Type': 'application/json'} + + try: + response = requests.get( + api_url, + params={ + 'query': json.dumps(query), + 'clientType': 'AzureCli', + 'context': json.dumps(context) + }, + headers=headers) + except RequestException as ex: + _log_debug('requests.get() exception: %s', ex) + else: + if subscription_id is None: + _log_debug(TELEMETRY_MISSING_SUBSCRIPTION_ID_STR) + if correlation_id is None: + _log_debug(TELEMETRY_MISSING_CORRELATION_ID_STR) + + _log_debug(UNABLE_TO_CALL_SERVICE_STR) + + return response diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/failure_recovery_recommendation.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/failure_recovery_recommendation.py new file mode 100644 index 00000000000..e52c9cb567b --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/failure_recovery_recommendation.py @@ -0,0 +1,64 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + + +def assert_has_split_method(field, value): + if not getattr(value, 'split') or not callable(value.split): + raise TypeError(f'value assigned to `{field}` must contain split method') + + +class FailureRecoveryRecommendation(): + def __init__(self, data): + data['SuccessCommand'] = data.get('SuccessCommand', '') + data['SuccessCommand_Parameters'] = data.get('SuccessCommand_Parameters', '') + data['SuccessCommand_ArgumentPlaceholders'] = data.get('SuccessCommand_ArgumentPlaceholders', '') + + self._command = data['SuccessCommand'] + self._parameters = data['SuccessCommand_Parameters'] + self._placeholders = data['SuccessCommand_ArgumentPlaceholders'] + + for attr in ('_parameters', '_placeholders'): + value = getattr(self, attr) + value = '' if value == '{}' else value + setattr(self, attr, value) + + @property + def command(self): + return self._command + + @command.setter + def command(self, value): + self._command = value + + @property + def parameters(self): + return self._parameters.split(',') + + @parameters.setter + def parameters(self, value): + assert_has_split_method('parameters', value) + self._parameters = value + + @property + def placeholders(self): + return self._placeholders.split(',') + + @placeholders.setter + def placeholders(self, value): + assert_has_split_method('placeholders', value) + self._placeholders = value + + def __str__(self): + parameter_and_argument_buffer = [] + + for pair in zip(self.parameters, self.placeholders): + parameter_and_argument_buffer.append(' '.join(pair)) + + return f"az {self.command} {' '.join(parameter_and_argument_buffer)}" + + def __eq__(self, value): + return (self.command == value.command and + self.parameters == value.parameters and + self.placeholders == value.placeholders) diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/__init__.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/__init__.py new file mode 100644 index 00000000000..99c0f28cd71 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/__init__.py @@ -0,0 +1,5 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ----------------------------------------------------------------------------- diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/__init__.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/__init__.py new file mode 100644 index 00000000000..99c0f28cd71 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/__init__.py @@ -0,0 +1,5 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ----------------------------------------------------------------------------- diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/_commands.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/_commands.py new file mode 100644 index 00000000000..32f97e1a602 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/_commands.py @@ -0,0 +1,132 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from collections import namedtuple +from enum import Enum + +GLOBAL_ARGS = set(('--debug', '--verbose', '--help', '--only-show-errors', '--output', '--query')) +GLOBAL_ARG_BLACKLIST = set(('--debug', '--verbose')) +GLOBAL_ARG_WHITELIST = GLOBAL_ARGS.difference(GLOBAL_ARG_BLACKLIST) +GLOBAL_ARGS_SHORTHAND_MAP = {'-h': '--help', '-o': '--output'} +GLOBAL_ARG_LIST = tuple(GLOBAL_ARGS) + tuple(GLOBAL_ARGS_SHORTHAND_MAP.keys()) + +Arguments = namedtuple('Arguments', ['actual', 'expected']) +CliCommand = namedtuple('CliCommand', ['module', 'command', 'args']) + + +def get_expected_args(args): + return [arg for arg in args if arg.startswith('--')] + + +VM_MODULE_ARGS = ['-g', '--name', '-n', '--resource-group', '--subscription'] +VM_MODULE_EXPECTED_ARGS = get_expected_args(VM_MODULE_ARGS) + +VM_SHOW_ARGS = Arguments( + actual=VM_MODULE_ARGS, + expected=VM_MODULE_EXPECTED_ARGS +) + +_VM_CREATE_ARGS = ['--zone', '-z', '--vmss', '--location', '-l', '--nsg', '--subnet'] + +VM_CREATE_ARGS = Arguments( + actual=VM_MODULE_ARGS + _VM_CREATE_ARGS, + expected=VM_MODULE_EXPECTED_ARGS + get_expected_args(_VM_CREATE_ARGS) +) + +ACCOUNT_ARGS = Arguments( + actual=[], + expected=[] +) + +ACCOUNT_SET_ARGS = Arguments( + actual=['-s', '--subscription'], + expected=['--subscription'] +) + +EXTENSION_LIST_ARGS = Arguments( + actual=['--foo', '--bar'], + expected=[] +) + +AI_DID_YOU_MEAN_THIS_VERSION_ARGS = Arguments( + actual=['--baz'], + expected=[] +) + +KUSTO_CLUSTER_CREATE_ARGS = Arguments( + actual=['-l', '-g', '--no-wait'], + expected=['--location', '--resource-group', '--no-wait'] +) + + +def add_global_args(args, global_args=GLOBAL_ARG_LIST): + expected_global_args = list(GLOBAL_ARG_WHITELIST) + args.actual.extend(global_args) + args.expected.extend(expected_global_args) + return args + + +class AzCommandType(Enum): + VM_SHOW = CliCommand( + module='vm', + command='vm show', + args=add_global_args(VM_SHOW_ARGS) + ) + VM_CREATE = CliCommand( + module='vm', + command='vm create', + args=add_global_args(VM_CREATE_ARGS) + ) + ACCOUNT = CliCommand( + module='account', + command='account', + args=add_global_args(ACCOUNT_ARGS) + ) + ACCOUNT_SET = CliCommand( + module='account', + command='account set', + args=add_global_args(ACCOUNT_SET_ARGS) + ) + EXTENSION_LIST = CliCommand( + module='extension', + command='extension list', + args=add_global_args(EXTENSION_LIST_ARGS) + ) + AI_DID_YOU_MEAN_THIS_VERSION = CliCommand( + module='ai-did-you-mean-this', + command='ai-did-you-mean-this version', + args=add_global_args(AI_DID_YOU_MEAN_THIS_VERSION_ARGS) + ) + KUSTO_CLUSTER_CREATE = CliCommand( + module='kusto', + command='kusto cluster create', + args=add_global_args(KUSTO_CLUSTER_CREATE_ARGS) + ) + + def __init__(self, module, command, args): + self._expected_args = list(sorted(args.expected)) + self._args = args.actual + self._module = module + self._command = command + + @property + def parameters(self): + return self._args + + @property + def expected_parameters(self): + return ','.join(self._expected_args) + + @property + def module(self): + return self._module + + @property + def command(self): + return self._command + + +def get_commands(): + return list({command_type.command for command_type in AzCommandType}) diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/_mock.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/_mock.py new file mode 100644 index 00000000000..9617c48d3ba --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/_mock.py @@ -0,0 +1,103 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import os +import json +import unittest.mock as mock +from enum import Enum, auto +from http import HTTPStatus +from collections import namedtuple +from contextlib import contextmanager + +import requests + +from azext_ai_did_you_mean_this.failure_recovery_recommendation import FailureRecoveryRecommendation + +# mock service call context attributes +MOCK_UUID = '00000000-0000-0000-0000-000000000000' +MOCK_VERSION = '2.4.0' + +# mock recommendation data constants +MOCK_MODEL_DIR = 'model' +MOCK_RECOMMENDATION_MODEL_FILENAME = 'recommendations.json' + + +RecommendationData = namedtuple('RecommendationData', ['recommendations', 'arguments', 'user_fault_type', 'extension']) + + +class UserFaultType(Enum): + MISSING_REQUIRED_SUBCOMMAND = auto() + NOT_IN_A_COMMAND_GROUP = auto() + EXPECTED_AT_LEAST_ONE_ARGUMENT = auto() + UNRECOGNIZED_ARGUMENTS = auto() + INVALID_JMESPATH_QUERY = auto() + NOT_APPLICABLE = auto() + + +def get_mock_recommendation_model_path(folder): + return os.path.join(folder, MOCK_MODEL_DIR, MOCK_RECOMMENDATION_MODEL_FILENAME) + + +def _parse_entity(entity): + kwargs = {} + kwargs['recommendations'] = entity.get('recommendations', []) + kwargs['arguments'] = entity.get('arguments', '') + kwargs['extension'] = entity.get('extension', None) + kwargs['user_fault_type'] = UserFaultType[entity.get('user_fault_type', 'not_applicable').upper()] + return RecommendationData(**kwargs) + + +class MockRecommendationModel(): + MODEL = None + NO_DATA = None + + @classmethod + def load(cls, path): + content = None + model = {} + + with open(os.path.join(path), 'r') as test_recommendation_data_file: + content = json.load(test_recommendation_data_file) + + for command, entity in content.items(): + model[command] = _parse_entity(entity) + + cls.MODEL = model + cls.NO_DATA = _parse_entity({}) + + @classmethod + def create_mock_aladdin_service_http_response(cls, command): + mock_response = requests.Response() + mock_response.status_code = HTTPStatus.OK.value + data = cls.get_recommendation_data(command) + mock_response._content = bytes(json.dumps(data.recommendations), 'utf-8') # pylint: disable=protected-access + return mock_response + + @classmethod + def get_recommendation_data(cls, command): + return cls.MODEL.get(command, cls.NO_DATA) + + @classmethod + def get_recommendations(cls, command): + data = cls.get_recommendation_data(command) + recommendations = [FailureRecoveryRecommendation(recommendation) for recommendation in data.recommendations] + return recommendations + + @classmethod + def get_test_cases(cls): + cases = [] + model = cls.MODEL or {} + for command, entity in model.items(): + cases.append((command, entity)) + return cases + + +@contextmanager +def mock_aladdin_service_call(command): + handlers = {} + handler = handlers.get(command, MockRecommendationModel.create_mock_aladdin_service_http_response) + + with mock.patch('requests.get', return_value=handler(command)): + yield None diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/aladdin_scenario_test_base.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/aladdin_scenario_test_base.py new file mode 100644 index 00000000000..ba8215b698e --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/aladdin_scenario_test_base.py @@ -0,0 +1,152 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import re +import logging +import unittest.mock as mock + +from azure_devtools.scenario_tests import mock_in_unit_test +from azure.cli.testsdk import ScenarioTest + +from azext_ai_did_you_mean_this._const import UNABLE_TO_HELP_FMT_STR, RECOMMENDATION_HEADER_FMT_STR +from azext_ai_did_you_mean_this._cmd_table import CommandTable +from azext_ai_did_you_mean_this.tests.latest._mock import MOCK_UUID, MOCK_VERSION +from azext_ai_did_you_mean_this.custom import recommend_recovery_options +from azext_ai_did_you_mean_this.tests.latest._mock import UserFaultType + +TELEMETRY_MODULE = 'azure.cli.core.telemetry' +TELEMETRY_SESSION_OBJECT = f'{TELEMETRY_MODULE}._session' + +USER_FAULT_TYPE_KEYWORDS = { + UserFaultType.EXPECTED_AT_LEAST_ONE_ARGUMENT: 'expected', + UserFaultType.INVALID_JMESPATH_QUERY: 'jmespath', + UserFaultType.MISSING_REQUIRED_SUBCOMMAND: '_subcommand', + UserFaultType.NOT_IN_A_COMMAND_GROUP: 'command group', + UserFaultType.UNRECOGNIZED_ARGUMENTS: 'unrecognized' +} + +FMT_STR_PATTERN_REGEX = r'\[[^\]]+\]|{[^}]+}' +SUGGEST_AZ_FIND_PATTERN_REGEX = re.sub(FMT_STR_PATTERN_REGEX, r'.*', UNABLE_TO_HELP_FMT_STR) +SHOW_RECOMMENDATIONS_PATTERN_REGEX = re.sub(FMT_STR_PATTERN_REGEX, r'.*', RECOMMENDATION_HEADER_FMT_STR) + + +def patch_ids(unit_test): + def _mock_uuid(*args, **kwargs): # pylint: disable=unused-argument + return MOCK_UUID + + mock_in_unit_test(unit_test, + f'{TELEMETRY_SESSION_OBJECT}.correlation_id', + _mock_uuid()) + mock_in_unit_test(unit_test, + f'{TELEMETRY_MODULE}._get_azure_subscription_id', + _mock_uuid) + + +def patch_version(unit_test): + mock_in_unit_test(unit_test, + 'azure.cli.core.__version__', + MOCK_VERSION) + + +def patch_telemetry(unit_test): + mock_in_unit_test(unit_test, + 'azure.cli.core.telemetry.is_telemetry_enabled', + lambda: True) + + +class AladdinScenarioTest(ScenarioTest): + def __init__(self, method_name, **kwargs): + super().__init__(method_name, **kwargs) + + default_telemetry_patches = { + patch_ids, + patch_version, + patch_telemetry + } + + self._exception = None + self._exit_code = None + self._parser_error_msg = '' + self._recommendation_msg = '' + self._recommender_positional_arguments = None + + self.telemetry_patches = kwargs.pop('telemetry_patches', default_telemetry_patches) + self.recommendations = [] + + def setUp(self): + super().setUp() + + for patch in self.telemetry_patches: + patch(self) + + def cmd(self, command, checks=None, expect_failure=False, expect_user_fault_failure=False): + from azure.cli.core.azlogging import AzCliLogging + + func = recommend_recovery_options + logger_name = AzCliLogging._COMMAND_METADATA_LOGGER # pylint: disable=protected-access + base = super() + + def _hook(*args, **kwargs): + self._recommender_positional_arguments = args + result = func(*args, **kwargs) + self.recommendations = result + return result + + def run_cmd(): + base.cmd(command, checks=checks, expect_failure=expect_failure) + + with mock.patch('azext_ai_did_you_mean_this.custom.recommend_recovery_options', wraps=_hook): + with self.assertLogs(logger_name, level=logging.ERROR) as parser_logs: + if expect_user_fault_failure: + with self.assertRaises(SystemExit) as cm: + run_cmd() + + self._exception = cm.exception + self._exit_code = self._exception.code + self._parser_error_msg = '\n'.join(parser_logs.output) + self._recommendation_msg = '\n'.join(self.recommendations) + + if expect_user_fault_failure: + self.assert_cmd_was_user_fault_failure() + else: + run_cmd() + + if expect_user_fault_failure: + self.assert_cmd_table_not_empty() + self.assert_user_fault_is_of_correct_type(expect_user_fault_failure) + + def assert_user_fault_is_of_correct_type(self, expect_user_fault_failure): + # check the user fault type where applicable + if isinstance(expect_user_fault_failure, UserFaultType): + keyword = USER_FAULT_TYPE_KEYWORDS.get(expect_user_fault_failure, None) + if keyword: + self.assertRegex(self._parser_error_msg, keyword) + + def assert_cmd_was_user_fault_failure(self): + is_user_fault_failure = (isinstance(self._exception, SystemExit) and + self._exit_code == 2) + + self.assertTrue(is_user_fault_failure) + + def assert_cmd_table_not_empty(self): + self.assertIsNotNone(CommandTable.CMD_TBL) + + def assert_recommendations_were_shown(self): + self.assertRegex(self._recommendation_msg, SHOW_RECOMMENDATIONS_PATTERN_REGEX) + + def assert_az_find_was_suggested(self): + self.assertRegex(self._recommendation_msg, SUGGEST_AZ_FIND_PATTERN_REGEX) + + def assert_nothing_is_shown(self): + self.assertEqual(self._recommendation_msg, '') + + @property + def cli_version(self): + from azure.cli.core import __version__ as core_version + return core_version + + @property + def recommender_postional_arguments(self): + return self._recommender_positional_arguments diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/model/recommendations.json b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/model/recommendations.json new file mode 100644 index 00000000000..5359be372f9 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/model/recommendations.json @@ -0,0 +1,75 @@ +{ + "account": { + "recommendations": [ + { + "SuccessCommand": "account list", + "SuccessCommand_Parameters": "", + "SuccessCommand_ArgumentPlaceholders": "" + }, + { + "SuccessCommand": "account show", + "SuccessCommand_Parameters": "", + "SuccessCommand_ArgumentPlaceholders": "" + }, + { + "SuccessCommand": "account set", + "SuccessCommand_Parameters": "--subscription", + "SuccessCommand_ArgumentPlaceholders": "Subscription" + } + ], + "user_fault_type": "missing_required_subcommand" + + }, + "boi": { + "user_fault_type": "not_in_a_command_group" + }, + "vm show": { + "arguments": "--name \"BigJay\" --ids", + "user_fault_type": "expected_at_least_one_argument" + }, + "ai-did-you-mean-this ve": { + "user_fault_type": "not_in_a_command_group", + "extension": "ai-did-you-mean-this" + }, + "ai-did-you-mean-this version": { + "user_fault_type": "unrecognized_arguments", + "arguments": "--name \"Christopher\"", + "extension": "ai-did-you-mean-this" + }, + "extension": { + "recommendations": [ + { + "SuccessCommand": "extension list", + "SuccessCommand_Parameters": "", + "SuccessCommand_ArgumentPlaceholders": "" + } + ], + "user_fault_type": "missing_required_subcommand" + }, + "vm": { + "recommendations": [ + { + "SuccessCommand": "vm list", + "SuccessCommand_Parameters": "", + "SuccessCommand_ArgumentPlaceholders": "" + } + ], + "user_fault_type": "missing_required_subcommand", + "arguments": "--debug" + }, + "account get-access-token": { + "user_fault_type": "unrecognized_arguments", + "arguments": "--test a --debug" + }, + "vm list": { + "recommendations": [ + { + "SuccessCommand": "vm list", + "SuccessCommand_Parameters": "--output,--query", + "SuccessCommand_ArgumentPlaceholders": "json,\"[].id\"" + } + ], + "arguments": "--query \".id\"", + "user_fault_type": "invalid_jmespath_query" + } +} \ No newline at end of file diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/test_ai_did_you_mean_this_scenario.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/test_ai_did_you_mean_this_scenario.py new file mode 100644 index 00000000000..fa9578d1583 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/test_ai_did_you_mean_this_scenario.py @@ -0,0 +1,75 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import os +import unittest +import unittest.mock as mock +import json +from http import HTTPStatus +from collections import defaultdict + +import requests + +from azext_ai_did_you_mean_this.custom import call_aladdin_service, get_recommendations_from_http_response +from azext_ai_did_you_mean_this._cmd_table import CommandTable +from azext_ai_did_you_mean_this.tests.latest._mock import ( + get_mock_recommendation_model_path, + mock_aladdin_service_call, + MockRecommendationModel, + UserFaultType +) +from azext_ai_did_you_mean_this.tests.latest.aladdin_scenario_test_base import AladdinScenarioTest +from azext_ai_did_you_mean_this.tests.latest._commands import AzCommandType + +TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), '..')) + + +class AiDidYouMeanThisScenarioTest(AladdinScenarioTest): + @classmethod + def setUpClass(cls): + super().setUpClass() + + MockRecommendationModel.load(get_mock_recommendation_model_path(TEST_DIR)) + cls.test_cases = MockRecommendationModel.get_test_cases() + + def setUp(self): + super().setUp() + + def test_ai_did_you_mean_this_aladdin_service_call(self): + for command, entity in self.test_cases: + tokens = entity.arguments.split() + parameters = [token for token in tokens if token.startswith('-')] + + with mock_aladdin_service_call(command): + response = call_aladdin_service(command, parameters, self.cli_version) + + self.assertEqual(HTTPStatus.OK, response.status_code) + recommendations = get_recommendations_from_http_response(response) + expected_recommendations = MockRecommendationModel.get_recommendations(command) + self.assertEquals(recommendations, expected_recommendations) + + def test_ai_did_you_mean_this_recommendations_for_user_fault_commands(self): + for command, entity in self.test_cases: + args = entity.arguments + command_with_args = command if not args else f'{command} {args}' + + with mock_aladdin_service_call(command): + self.cmd(command_with_args, expect_user_fault_failure=entity.user_fault_type) + + self.assert_cmd_table_not_empty() + cmd_tbl = CommandTable.CMD_TBL + + _version, _command, _parameters, _extension = self.recommender_postional_arguments + partial_command_match = command and any(cmd.startswith(command) for cmd in cmd_tbl.keys()) + self.assertEqual(_version, self.cli_version) + self.assertEqual(_command, command if partial_command_match else '') + self.assertEqual(bool(_extension), bool(entity.extension)) + + if entity.recommendations: + self.assert_recommendations_were_shown() + elif partial_command_match and not entity.extension: + self.assert_az_find_was_suggested() + else: + self.assert_nothing_is_shown() diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/test_normalize_and_sort_parameters.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/test_normalize_and_sort_parameters.py new file mode 100644 index 00000000000..06729b8b39c --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/test_normalize_and_sort_parameters.py @@ -0,0 +1,81 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import unittest +from enum import Enum, auto + +from azure.cli.core.mock import DummyCli +from azure.cli.core import MainCommandsLoader + +from azext_ai_did_you_mean_this.custom import normalize_and_sort_parameters +from azext_ai_did_you_mean_this.tests.latest._commands import get_commands, AzCommandType + + +class TestNormalizeAndSortParameters(unittest.TestCase): + @classmethod + def setUpClass(cls): + super(TestNormalizeAndSortParameters, cls).setUpClass() + + from knack.events import EVENT_INVOKER_POST_CMD_TBL_CREATE + from azure.cli.core.commands.events import EVENT_INVOKER_PRE_LOAD_ARGUMENTS, EVENT_INVOKER_POST_LOAD_ARGUMENTS + from azure.cli.core.commands.arm import register_global_subscription_argument, register_ids_argument + + # setup a dummy CLI with a valid invocation object. + cls.cli = DummyCli() + cli_ctx = cls.cli.commands_loader.cli_ctx + cls.cli.invocation = cli_ctx.invocation_cls(cli_ctx=cli_ctx, + parser_cls=cli_ctx.parser_cls, + commands_loader_cls=cli_ctx.commands_loader_cls, + help_cls=cli_ctx.help_cls) + # load command table for every module + cmd_loader = cls.cli.invocation.commands_loader + cmd_loader.load_command_table(None) + + # Note: Both of the below events rely on EVENT_INVOKER_POST_CMD_TBL_CREATE. + # register handler for adding subscription argument + register_global_subscription_argument(cli_ctx) + # register handler for adding ids argument. + register_ids_argument(cli_ctx) + + cli_ctx.raise_event(EVENT_INVOKER_PRE_LOAD_ARGUMENTS, commands_loader=cmd_loader) + + # load arguments for each command + for cmd in get_commands(): + # simulate command invocation by filling in required metadata. + cmd_loader.command_name = cmd + cli_ctx.invocation.data['command_string'] = cmd + # load argument info for the given command. + cmd_loader.load_arguments(cmd) + + cli_ctx.raise_event(EVENT_INVOKER_POST_LOAD_ARGUMENTS, commands_loader=cmd_loader) + cli_ctx.raise_event(EVENT_INVOKER_POST_CMD_TBL_CREATE, commands_loader=cmd_loader) + + cls.cmd_tbl = cmd_loader.command_table + + def test_custom_normalize_and_sort_parameters(self): + for cmd in AzCommandType: + command, parameters = normalize_and_sort_parameters(self.cmd_tbl, cmd.command, cmd.parameters) + self.assertEqual(parameters, cmd.expected_parameters) + self.assertEqual(command, cmd.command) + + def test_custom_normalize_and_sort_parameters_remove_invalid_command_token(self): + for cmd in AzCommandType: + cmd_with_invalid_token = f'{cmd.command} oops' + command, parameters = normalize_and_sort_parameters(self.cmd_tbl, cmd_with_invalid_token, cmd.parameters) + self.assertEqual(parameters, cmd.expected_parameters) + self.assertEqual(command, cmd.command) + + def test_custom_normalize_and_sort_parameters_empty_parameter_list(self): + cmd = AzCommandType.ACCOUNT_SET + command, parameters = normalize_and_sort_parameters(self.cmd_tbl, cmd.command, '') + self.assertEqual(parameters, '') + self.assertEqual(command, cmd.command) + + def test_custom_normalize_and_sort_parameters_invalid_command(self): + invalid_cmd = 'Lorem ipsum.' + command, parameters = normalize_and_sort_parameters(self.cmd_tbl, invalid_cmd, ['--foo', '--baz']) + self.assertEqual(parameters, '') + # verify that recursive parsing removes the last invalid whitespace delimited token. + self.assertEqual(command, 'Lorem') diff --git a/src/ai-did-you-mean-this/setup.cfg b/src/ai-did-you-mean-this/setup.cfg new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai-did-you-mean-this/setup.py b/src/ai-did-you-mean-this/setup.py new file mode 100644 index 00000000000..dc93ac897f0 --- /dev/null +++ b/src/ai-did-you-mean-this/setup.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + + +from codecs import open +from setuptools import setup, find_packages +try: + from azure_bdist_wheel import cmdclass +except ImportError: + from distutils import log as logger + logger.warn("Wheel is not available, disabling bdist_wheel hook") + +VERSION = '0.1.0' + +# The full list of classifiers is available at +# https://pypi.python.org/pypi?%3Aaction=list_classifiers +CLASSIFIERS = [ + 'Development Status :: 4 - Beta', + 'Intended Audience :: Developers', + 'Intended Audience :: System Administrators', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'License :: OSI Approved :: MIT License', +] + +DEPENDENCIES = [] + +with open('README.md', 'r', encoding='utf-8') as f: + README = f.read() +with open('HISTORY.rst', 'r', encoding='utf-8') as f: + HISTORY = f.read() + +setup( + name='ai_did_you_mean_this', + version=VERSION, + description='Recommend recovery options on failure.', + # TODO: Update author and email, if applicable + author="Christopher O'Toole", + author_email='chotool@microsoft.com', + # TODO: consider pointing directly to your source code instead of the generic repo + url='https://github.com/Azure/azure-cli-extensions/ai-did-you-mean-this', + long_description=README + '\n\n' + HISTORY, + license='MIT', + classifiers=CLASSIFIERS, + packages=find_packages(), + install_requires=DEPENDENCIES, + package_data={'azext_ai_did_you_mean_this': ['azext_metadata.json']}, +)