From 8305ee4eecc897113b41c93cc0a6ed494fb69c79 Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Fri, 3 Apr 2020 17:14:58 -0700 Subject: [PATCH 01/33] Setup extension scaffolding, create dummy version command, add help/history/readme info. --- .github/CODEOWNERS | 2 + src/ai-did-you-mean-this/HISTORY.rst | 10 ++++ src/ai-did-you-mean-this/README.rst | 6 ++ .../azext_ai-did-you-mean-this/__init__.py | 29 +++++++++ .../azext_ai-did-you-mean-this/_help.py | 18 ++++++ .../azext_metadata.json | 4 ++ .../azext_ai-did-you-mean-this/commands.py | 14 +++++ .../azext_ai-did-you-mean-this/custom.py | 11 ++++ .../tests/__init__.py | 5 ++ .../tests/latest/__init__.py | 5 ++ .../test_ai_did_you_mean_this_scenario.py | 18 ++++++ src/ai-did-you-mean-this/setup.cfg | 2 + src/ai-did-you-mean-this/setup.py | 59 +++++++++++++++++++ 13 files changed, 183 insertions(+) create mode 100644 src/ai-did-you-mean-this/HISTORY.rst create mode 100644 src/ai-did-you-mean-this/README.rst create mode 100644 src/ai-did-you-mean-this/azext_ai-did-you-mean-this/__init__.py create mode 100644 src/ai-did-you-mean-this/azext_ai-did-you-mean-this/_help.py create mode 100644 src/ai-did-you-mean-this/azext_ai-did-you-mean-this/azext_metadata.json create mode 100644 src/ai-did-you-mean-this/azext_ai-did-you-mean-this/commands.py create mode 100644 src/ai-did-you-mean-this/azext_ai-did-you-mean-this/custom.py create mode 100644 src/ai-did-you-mean-this/azext_ai-did-you-mean-this/tests/__init__.py create mode 100644 src/ai-did-you-mean-this/azext_ai-did-you-mean-this/tests/latest/__init__.py create mode 100644 src/ai-did-you-mean-this/azext_ai-did-you-mean-this/tests/latest/test_ai_did_you_mean_this_scenario.py create mode 100644 src/ai-did-you-mean-this/setup.cfg create mode 100644 src/ai-did-you-mean-this/setup.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d6a3414c290..59b91cc2533 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -91,3 +91,5 @@ /src/powerbidedicated/ @Juliehzl /src/blueprint/ @fengzhou-msft + +/src/azext_ai-did-you-mean-this/ @christopher-o-toole 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.rst b/src/ai-did-you-mean-this/README.rst new file mode 100644 index 00000000000..888b796e56e --- /dev/null +++ b/src/ai-did-you-mean-this/README.rst @@ -0,0 +1,6 @@ +Microsoft Azure CLI 'AI Did You Mean This' Extension +========================================== + +Improve user experience by suggesting recovery options for common CLI failures. + +This extension extends the default error handling behavior to include recommendations for recovery. Recommendations are based on how other users were successful after they encountered the same failure. \ 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..1417cd9dab9 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai-did-you-mean-this/__init__.py @@ -0,0 +1,29 @@ +# -------------------------------------------------------------------------------------------- +# 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 azext_ai_did_you_mean_this._help import helps # pylint: disable=unused-import + +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(AiDidYouMeanThisCommandsLoader, self).__init__(cli_ctx=cli_ctx, + custom_command_type=ai_did_you_mean_this_custom) + + 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/_help.py b/src/ai-did-you-mean-this/azext_ai-did-you-mean-this/_help.py new file mode 100644 index 00000000000..df22ac7638c --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai-did-you-mean-this/_help.py @@ -0,0 +1,18 @@ +# 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. +""" \ No newline at end of file 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..2892e1607d2 --- /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.2.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..6ddd5c36c49 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai-did-you-mean-this/commands.py @@ -0,0 +1,14 @@ +# -------------------------------------------------------------------------------------------- +# 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') as g: + g.custom_command('version', 'show_extension_version') + + with self.command_group('ai-did-you-mean-this', is_preview=True): + pass 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..eda7d117fa7 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai-did-you-mean-this/custom.py @@ -0,0 +1,11 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from knack.util import CLIError + +# Commands +def show_extension_version(): + from setup import VERSION + print(f'Current version: {VERSION}') 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..2dcf9bb68b3 --- /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. +# ----------------------------------------------------------------------------- \ No newline at end of file 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..2dcf9bb68b3 --- /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. +# ----------------------------------------------------------------------------- \ 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..6cbcb30aae5 --- /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,18 @@ +# -------------------------------------------------------------------------------------------- +# 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 mock +import requests + +from azure_devtools.scenario_tests import AllowLargeResponse +from azure.cli.testsdk import (ScenarioTest, ResourceGroupPreparer) + + +TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), '..')) + +class AiDidYouMeanThisScenarioTest(unittest.TestCase): + pass \ No newline at end of file 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..3c6e79cf31d --- /dev/null +++ b/src/ai-did-you-mean-this/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=1 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..b34b16967e9 --- /dev/null +++ b/src/ai-did-you-mean-this/setup.py @@ -0,0 +1,59 @@ +#!/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.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'License :: OSI Approved :: MIT License', +] + +# TODO: Add any additional SDK dependencies here +DEPENDENCIES = [ + 'azure-cli-core' +] + +with open('README.rst', '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', + 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']}, +) \ No newline at end of file From c29b090f8f20be0df20fa8f356766534a6166bf8 Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Fri, 3 Apr 2020 18:46:15 -0700 Subject: [PATCH 02/33] Add v0 recommendation file. Handle general extension logic for when recommendations aren't available and/or the user has an outdated CLI. --- .../azext_ai-did-you-mean-this/custom.py | 11 --- .../__init__.py | 7 ++ .../_help.py | 1 - .../azext_metadata.json | 0 .../commands.py | 0 .../azext_ai_did_you_mean_this/custom.py | 76 +++++++++++++++++++ .../tests/__init__.py | 0 .../tests/latest/__init__.py | 0 .../test_ai_did_you_mean_this_scenario.py | 0 src/ai-did-you-mean-this/setup.py | 5 +- 10 files changed, 84 insertions(+), 16 deletions(-) delete mode 100644 src/ai-did-you-mean-this/azext_ai-did-you-mean-this/custom.py rename src/ai-did-you-mean-this/{azext_ai-did-you-mean-this => azext_ai_did_you_mean_this}/__init__.py (80%) rename src/ai-did-you-mean-this/{azext_ai-did-you-mean-this => azext_ai_did_you_mean_this}/_help.py (99%) rename src/ai-did-you-mean-this/{azext_ai-did-you-mean-this => azext_ai_did_you_mean_this}/azext_metadata.json (100%) rename src/ai-did-you-mean-this/{azext_ai-did-you-mean-this => azext_ai_did_you_mean_this}/commands.py (100%) create mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/custom.py rename src/ai-did-you-mean-this/{azext_ai-did-you-mean-this => azext_ai_did_you_mean_this}/tests/__init__.py (100%) rename src/ai-did-you-mean-this/{azext_ai-did-you-mean-this => azext_ai_did_you_mean_this}/tests/latest/__init__.py (100%) rename src/ai-did-you-mean-this/{azext_ai-did-you-mean-this => azext_ai_did_you_mean_this}/tests/latest/test_ai_did_you_mean_this_scenario.py (100%) 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 deleted file mode 100644 index eda7d117fa7..00000000000 --- a/src/ai-did-you-mean-this/azext_ai-did-you-mean-this/custom.py +++ /dev/null @@ -1,11 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -from knack.util import CLIError - -# Commands -def show_extension_version(): - from setup import VERSION - print(f'Current version: {VERSION}') 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 similarity index 80% rename from src/ai-did-you-mean-this/azext_ai-did-you-mean-this/__init__.py rename to src/ai-did-you-mean-this/azext_ai_did_you_mean_this/__init__.py index 1417cd9dab9..daa6efeee97 100644 --- 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 @@ -7,6 +7,11 @@ from azext_ai_did_you_mean_this._help import helps # pylint: disable=unused-import +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 + class AiDidYouMeanThisCommandsLoader(AzCommandsLoader): def __init__(self, cli_ctx=None): @@ -16,6 +21,8 @@ def __init__(self, cli_ctx=None): operations_tmpl='azext_ai_did_you_mean_this.custom#{}') super(AiDidYouMeanThisCommandsLoader, self).__init__(cli_ctx=cli_ctx, custom_command_type=ai_did_you_mean_this_custom) + + inject_functions_into_core() def load_command_table(self, args): from azext_ai_did_you_mean_this.commands import load_command_table 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 similarity index 99% rename from src/ai-did-you-mean-this/azext_ai-did-you-mean-this/_help.py rename to src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_help.py index df22ac7638c..d99b4a3942a 100644 --- 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 @@ -6,7 +6,6 @@ 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. 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 similarity index 100% rename from src/ai-did-you-mean-this/azext_ai-did-you-mean-this/azext_metadata.json rename to src/ai-did-you-mean-this/azext_ai_did_you_mean_this/azext_metadata.json 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 similarity index 100% rename from src/ai-did-you-mean-this/azext_ai-did-you-mean-this/commands.py rename to src/ai-did-you-mean-this/azext_ai_did_you_mean_this/commands.py 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..e046296f263 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/custom.py @@ -0,0 +1,76 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import json +import os +import sys +import colorama + +from knack.util import CLIError + +RECOMMENDATION_FILE_PATH = 'src/ai-did-you-mean-this/azext_ai_did_you_mean_this/data/top_3_recommendations.json' +RECOMMENDATIONS = None + +with open(RECOMMENDATION_FILE_PATH) as recommendation_file: + RECOMMENDATIONS = json.load(recommendation_file) + +def style_message(msg): + if should_enable_styling(): + try: + msg = colorama.Style.BRIGHT + msg + colorama.Style.RESET_ALL + except KeyError: + pass + return msg + + +def should_enable_styling(): + try: + # Style if tty stream is available + if sys.stdout.isatty(): + return True + except AttributeError: + pass + return False + +# Commands +def show_extension_version(): + print(f'Current version: 0.1.0') + +def parse_recommendation(recommendation): + success_command = recommendation['SuccessCommand'] + success_command_parameters = recommendation['SuccessCommand_Parameters'] + succces_command_parameter_buffer = success_command_parameters.split(',') + success_command_placeholder_arguments = [] + + for parameter in succces_command_parameter_buffer: + argument = parameter[2:] + argument_buffer = [word.capitalize() for word in argument.split('-')] + success_command_placeholder_arguments.append(''.join(argument_buffer)) + + return success_command, succces_command_parameter_buffer, success_command_placeholder_arguments + +def recommend_recovery_options(version, command, parameters, extension): + if version in RECOMMENDATIONS: + command_recommendations = RECOMMENDATIONS[version] + + if command in command_recommendations and len(command_recommendations[command]) > 0: + print(f'\nHere are the most common ways users succeeded after [{command}] failed:') + + recommendations = command_recommendations[command] + + for recommendation in recommendations: + command, parameters, placeholders = parse_recommendation(recommendation) + parameter_and_argument_buffer = [] + + for pair in zip(parameters, placeholders): + parameter_and_argument_buffer.append(' '.join(pair)) + + print(f"\taz {command} {' '.join(parameter_and_argument_buffer)}") + else: + print(f'\nSorry I am not able to help with [{command}]' + f'\nTry running [az find "{command}"] to see examples of [{command}] from other users.') + else: + print(style_message("Better failure recovery recommendations are available from the latest version of the CLI. " + "Please update for the best experience.\n")) \ No newline at end of file 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 similarity index 100% rename from src/ai-did-you-mean-this/azext_ai-did-you-mean-this/tests/__init__.py rename to src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/__init__.py 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 similarity index 100% rename from src/ai-did-you-mean-this/azext_ai-did-you-mean-this/tests/latest/__init__.py rename to src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/__init__.py 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 similarity index 100% rename from src/ai-did-you-mean-this/azext_ai-did-you-mean-this/tests/latest/test_ai_did_you_mean_this_scenario.py rename to src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/test_ai_did_you_mean_this_scenario.py diff --git a/src/ai-did-you-mean-this/setup.py b/src/ai-did-you-mean-this/setup.py index b34b16967e9..5dbb681b42c 100644 --- a/src/ai-did-you-mean-this/setup.py +++ b/src/ai-did-you-mean-this/setup.py @@ -31,10 +31,7 @@ 'License :: OSI Approved :: MIT License', ] -# TODO: Add any additional SDK dependencies here -DEPENDENCIES = [ - 'azure-cli-core' -] +DEPENDENCIES = [] with open('README.rst', 'r', encoding='utf-8') as f: README = f.read() From ce8e6de0f7bcb8c552704628f9e0f0342d9ae01e Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Mon, 6 Apr 2020 10:11:02 -0700 Subject: [PATCH 03/33] Fix empty iterable check, add guard against extension usage. --- .../azext_ai_did_you_mean_this/custom.py | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) 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 index e046296f263..8072e2aa82d 100644 --- 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 @@ -52,25 +52,26 @@ def parse_recommendation(recommendation): return success_command, succces_command_parameter_buffer, success_command_placeholder_arguments def recommend_recovery_options(version, command, parameters, extension): - if version in RECOMMENDATIONS: - command_recommendations = RECOMMENDATIONS[version] + if extension is None: + if version in RECOMMENDATIONS: + command_recommendations = RECOMMENDATIONS[version] - if command in command_recommendations and len(command_recommendations[command]) > 0: - print(f'\nHere are the most common ways users succeeded after [{command}] failed:') + if command in command_recommendations and command_recommendations[command]: + print(f'\nHere are the most common ways users succeeded after [{command}] failed:') - recommendations = command_recommendations[command] - - for recommendation in recommendations: - command, parameters, placeholders = parse_recommendation(recommendation) - parameter_and_argument_buffer = [] + recommendations = command_recommendations[command] - for pair in zip(parameters, placeholders): - parameter_and_argument_buffer.append(' '.join(pair)) + for recommendation in recommendations: + command, parameters, placeholders = parse_recommendation(recommendation) + parameter_and_argument_buffer = [] - print(f"\taz {command} {' '.join(parameter_and_argument_buffer)}") + for pair in zip(parameters, placeholders): + parameter_and_argument_buffer.append(' '.join(pair)) + + print(f"\taz {command} {' '.join(parameter_and_argument_buffer)}") + else: + print(f'\nSorry I am not able to help with [{command}]' + f'\nTry running [az find "{command}"] to see examples of [{command}] from other users.') else: - print(f'\nSorry I am not able to help with [{command}]' - f'\nTry running [az find "{command}"] to see examples of [{command}] from other users.') - else: - print(style_message("Better failure recovery recommendations are available from the latest version of the CLI. " - "Please update for the best experience.\n")) \ No newline at end of file + print(style_message("Better failure recovery recommendations are available from the latest version of the CLI. " + "Please update for the best experience.\n")) From 2476efd01f97535eb21f9769cf6df8efea1e93f4 Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Mon, 6 Apr 2020 10:34:01 -0700 Subject: [PATCH 04/33] Disable linter warning for CLIError import, add more robust relative pathing to recommendation file. --- .../azext_ai_did_you_mean_this/custom.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 index 8072e2aa82d..1fdc84fa05c 100644 --- 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 @@ -6,11 +6,12 @@ import json import os import sys -import colorama +import colorama -from knack.util import CLIError +from knack.util import CLIError # pylint: disable=unused-import -RECOMMENDATION_FILE_PATH = 'src/ai-did-you-mean-this/azext_ai_did_you_mean_this/data/top_3_recommendations.json' +EXTENSION_DIR = os.path.dirname(os.path.realpath(__file__)) +RECOMMENDATION_FILE_PATH = os.path.join(EXTENSION_DIR, 'data/top_3_recommendations.json') RECOMMENDATIONS = None with open(RECOMMENDATION_FILE_PATH) as recommendation_file: From 3cb8aac5a64128f78c4004d1a8e1ae4436ef25e5 Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Tue, 7 Apr 2020 20:30:18 -0700 Subject: [PATCH 05/33] Add lookup table. Adjust minimum CLI version. Comment out parameter lookup table logic for now. --- .../azext_metadata.json | 2 +- .../azext_ai_did_you_mean_this/custom.py | 65 +++++++++++++++---- 2 files changed, 54 insertions(+), 13 deletions(-) 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 index 2892e1607d2..8cfc6da9485 100644 --- 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 @@ -1,4 +1,4 @@ { "azext.isPreview": true, - "azext.minCliCoreVersion": "2.2.0" + "azext.minCliCoreVersion": "2.3.1" } \ No newline at end of file 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 index 1fdc84fa05c..6a154967fbc 100644 --- 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 @@ -3,19 +3,35 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +import logging + import json import os import sys + import colorama +import azure.cli.core.telemetry as telemetry + +from knack.log import get_logger from knack.util import CLIError # pylint: disable=unused-import +logger = get_logger(__name__) + EXTENSION_DIR = os.path.dirname(os.path.realpath(__file__)) RECOMMENDATION_FILE_PATH = os.path.join(EXTENSION_DIR, 'data/top_3_recommendations.json') +#PARAMETER_LOOKUP_TABLE_FILE_PATH = os.path.join(EXTENSION_DIR, 'data/parameter_lookup_table.json') RECOMMENDATIONS = None +#PARAMETER_LOOKUP_TABLE = None +UPDATE_RECOMMENDATION_STR = ( + "Better failure recovery recommendations are available from the latest version of the CLI. " + "Please update for the best experience.\n" +) with open(RECOMMENDATION_FILE_PATH) as recommendation_file: RECOMMENDATIONS = json.load(recommendation_file) +#with open(PARAMETER_LOOKUP_TABLE_FILE_PATH) as paramter_lookup_table_file: + #PARAMETER_LOOKUP_TABLE = json.load(paramter_lookup_table_file) def style_message(msg): if should_enable_styling(): @@ -47,32 +63,57 @@ def parse_recommendation(recommendation): for parameter in succces_command_parameter_buffer: argument = parameter[2:] - argument_buffer = [word.capitalize() for word in argument.split('-')] - success_command_placeholder_arguments.append(''.join(argument_buffer)) + argument_buffer = [f'{word.capitalize()}' for word in argument.split('-')] + success_command_placeholder_arguments.append(f"{{{''.join(argument_buffer)}}}") return success_command, succces_command_parameter_buffer, success_command_placeholder_arguments +def log_debug(msg): + # TODO: see if there's a way to change the log foramtter locally without printing to stdout + prefix = '[Thoth]' + logger.debug('%s: %s', prefix, msg) + def recommend_recovery_options(version, command, parameters, extension): - if extension is None: + recommendations = [] + + # 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 earlier by the CommandInvoker object. + command = session.raw_command + if command: + log_debug(f'Setting command to [{command}] from telemtry.') + + def append(line): + recommendations.append(line) + + if extension: + log_debug('Detected extension. No action to perform.') + if not command: + log_debug('Command is empty. No action to perform.') + + if extension is None and command: if version in RECOMMENDATIONS: command_recommendations = RECOMMENDATIONS[version] if command in command_recommendations and command_recommendations[command]: - print(f'\nHere are the most common ways users succeeded after [{command}] failed:') + append(f'\nHere are the most common ways users succeeded after [{command}] failed:') - recommendations = command_recommendations[command] + top_recommendations = command_recommendations[command] - for recommendation in recommendations: - command, parameters, placeholders = parse_recommendation(recommendation) + for top_recommendation in top_recommendations: + command, parameters, placeholders = parse_recommendation(top_recommendation) parameter_and_argument_buffer = [] for pair in zip(parameters, placeholders): parameter_and_argument_buffer.append(' '.join(pair)) - print(f"\taz {command} {' '.join(parameter_and_argument_buffer)}") + append(f"\taz {command} {' '.join(parameter_and_argument_buffer)}") else: - print(f'\nSorry I am not able to help with [{command}]' - f'\nTry running [az find "{command}"] to see examples of [{command}] from other users.') + append(f'\nSorry I am not able to help with [{command}]' + f'\nTry running [az find "{command}"] to see examples of [{command}] from other users.') else: - print(style_message("Better failure recovery recommendations are available from the latest version of the CLI. " - "Please update for the best experience.\n")) + append(style_message(UPDATE_RECOMMENDATION_STR)) + + return recommendations From e7916b0f1ca8ceb54e630e2dbd3555dc465b5171 Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Mon, 13 Apr 2020 15:10:54 -0700 Subject: [PATCH 06/33] Move "not able to help" message to function, accept list of parameters, add Aladdin style parameter placeholders, remove lookup table references, resolved issue with generated recommendations. --- .../azext_ai_did_you_mean_this/__init__.py | 2 +- .../azext_ai_did_you_mean_this/custom.py | 57 +++++++++++-------- 2 files changed, 34 insertions(+), 25 deletions(-) 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 index daa6efeee97..bd61690ea04 100644 --- 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 @@ -21,7 +21,7 @@ def __init__(self, cli_ctx=None): operations_tmpl='azext_ai_did_you_mean_this.custom#{}') super(AiDidYouMeanThisCommandsLoader, self).__init__(cli_ctx=cli_ctx, custom_command_type=ai_did_you_mean_this_custom) - + inject_functions_into_core() def load_command_table(self, args): 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 index 6a154967fbc..aadb088fb59 100644 --- 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 @@ -3,8 +3,6 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -import logging - import json import os import sys @@ -20,9 +18,8 @@ EXTENSION_DIR = os.path.dirname(os.path.realpath(__file__)) RECOMMENDATION_FILE_PATH = os.path.join(EXTENSION_DIR, 'data/top_3_recommendations.json') -#PARAMETER_LOOKUP_TABLE_FILE_PATH = os.path.join(EXTENSION_DIR, 'data/parameter_lookup_table.json') RECOMMENDATIONS = None -#PARAMETER_LOOKUP_TABLE = None + UPDATE_RECOMMENDATION_STR = ( "Better failure recovery recommendations are available from the latest version of the CLI. " "Please update for the best experience.\n" @@ -30,8 +27,6 @@ with open(RECOMMENDATION_FILE_PATH) as recommendation_file: RECOMMENDATIONS = json.load(recommendation_file) -#with open(PARAMETER_LOOKUP_TABLE_FILE_PATH) as paramter_lookup_table_file: - #PARAMETER_LOOKUP_TABLE = json.load(paramter_lookup_table_file) def style_message(msg): if should_enable_styling(): @@ -55,18 +50,23 @@ def should_enable_styling(): def show_extension_version(): print(f'Current version: 0.1.0') +def get_values(comma_separated_values): + if not comma_separated_values: + return [] + return comma_separated_values.split(',') + def parse_recommendation(recommendation): success_command = recommendation['SuccessCommand'] success_command_parameters = recommendation['SuccessCommand_Parameters'] - succces_command_parameter_buffer = success_command_parameters.split(',') - success_command_placeholder_arguments = [] + success_command_argument_placeholders = recommendation['SuccessCommand_ArgumentPlaceholders'] - for parameter in succces_command_parameter_buffer: - argument = parameter[2:] - argument_buffer = [f'{word.capitalize()}' for word in argument.split('-')] - success_command_placeholder_arguments.append(f"{{{''.join(argument_buffer)}}}") + if not success_command_parameters: + success_command_argument_placeholders = '' - return success_command, succces_command_parameter_buffer, success_command_placeholder_arguments + parameter_buffer = get_values(success_command_parameters) + placeholder_buffer = get_values(success_command_argument_placeholders) + + return success_command, parameter_buffer, placeholder_buffer def log_debug(msg): # TODO: see if there's a way to change the log foramtter locally without printing to stdout @@ -80,7 +80,7 @@ def recommend_recovery_options(version, command, parameters, extension): if not command: # try to get the raw command field from telemetry. session = telemetry._session # pylint: disable=protected-access - # get the raw command parsed earlier by the CommandInvoker object. + # get the raw command parsed by the CommandInvoker object. command = session.raw_command if command: log_debug(f'Setting command to [{command}] from telemtry.') @@ -88,6 +88,10 @@ def recommend_recovery_options(version, command, parameters, extension): def append(line): recommendations.append(line) + def unable_to_help(command): + append(f'\nSorry I am not able to help with [{command}]' + f'\nTry running [az find "{command}"] to see examples of [{command}] from other users.') + if extension: log_debug('Detected extension. No action to perform.') if not command: @@ -98,21 +102,26 @@ def append(line): command_recommendations = RECOMMENDATIONS[version] if command in command_recommendations and command_recommendations[command]: - append(f'\nHere are the most common ways users succeeded after [{command}] failed:') + parameters = ','.join(parameters) + parameters = parameters if parameters in command_recommendations[command] else '' + + if parameters in command_recommendations[command]: + append(f'\nHere are the most common ways users succeeded after [{command}] failed:') - top_recommendations = command_recommendations[command] + top_recommendations = command_recommendations[command][parameters] - for top_recommendation in top_recommendations: - command, parameters, placeholders = parse_recommendation(top_recommendation) - parameter_and_argument_buffer = [] + for top_recommendation in top_recommendations: + command, parameters, placeholders = parse_recommendation(top_recommendation) + parameter_and_argument_buffer = [] - for pair in zip(parameters, placeholders): - parameter_and_argument_buffer.append(' '.join(pair)) + for pair in zip(parameters, placeholders): + parameter_and_argument_buffer.append(' '.join(pair)) - append(f"\taz {command} {' '.join(parameter_and_argument_buffer)}") + append(f"\taz {command} {' '.join(parameter_and_argument_buffer)}") + else: + unable_to_help(command) else: - append(f'\nSorry I am not able to help with [{command}]' - f'\nTry running [az find "{command}"] to see examples of [{command}] from other users.') + unable_to_help(command) else: append(style_message(UPDATE_RECOMMENDATION_STR)) From 89ccf9c8191375e3adb7b395c1bcf6b7b5bffe26 Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Mon, 13 Apr 2020 15:11:25 -0700 Subject: [PATCH 07/33] Fix typo in comment. --- src/ai-did-you-mean-this/azext_ai_did_you_mean_this/custom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index aadb088fb59..73a6282926d 100644 --- 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 @@ -69,7 +69,7 @@ def parse_recommendation(recommendation): return success_command, parameter_buffer, placeholder_buffer def log_debug(msg): - # TODO: see if there's a way to change the log foramtter locally without printing to stdout + # TODO: see if there's a way to change the log formatter locally without printing to stdout prefix = '[Thoth]' logger.debug('%s: %s', prefix, msg) From 3871f0a2f9b112fbe607e5d570f8c32b1f54af42 Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Mon, 13 Apr 2020 19:02:05 -0700 Subject: [PATCH 08/33] Remove comment. --- src/ai-did-you-mean-this/azext_ai_did_you_mean_this/custom.py | 1 - 1 file changed, 1 deletion(-) 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 index 73a6282926d..037c524ab1b 100644 --- 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 @@ -39,7 +39,6 @@ def style_message(msg): def should_enable_styling(): try: - # Style if tty stream is available if sys.stdout.isatty(): return True except AttributeError: From 0aaa994bad84c29e19b71cc8eed8dee11f7eede0 Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Mon, 13 Apr 2020 20:17:03 -0700 Subject: [PATCH 09/33] Clean up extension logic, adjust style based on flake8 and pylint. --- .../azext_ai_did_you_mean_this/__init__.py | 2 + .../azext_ai_did_you_mean_this/_help.py | 2 +- .../azext_ai_did_you_mean_this/commands.py | 1 + .../azext_ai_did_you_mean_this/custom.py | 104 +++++++++++++----- .../tests/__init__.py | 2 +- .../tests/latest/__init__.py | 2 +- .../test_ai_did_you_mean_this_scenario.py | 3 +- src/ai-did-you-mean-this/setup.py | 2 +- 8 files changed, 85 insertions(+), 33 deletions(-) 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 index bd61690ea04..356f1234674 100644 --- 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 @@ -7,11 +7,13 @@ from azext_ai_did_you_mean_this._help import helps # pylint: disable=unused-import + 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 + class AiDidYouMeanThisCommandsLoader(AzCommandsLoader): def __init__(self, cli_ctx=None): 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 index d99b4a3942a..8c8e70d9a85 100644 --- 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 @@ -14,4 +14,4 @@ helps['ai-did-you-mean-this version'] = """ type: command short-summary: Prints the extension version. -""" \ 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 index 6ddd5c36c49..cb59e4bcc0e 100644 --- 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 @@ -5,6 +5,7 @@ # pylint: disable=line-too-long + def load_command_table(self, _): with self.command_group('ai-did-you-mean-this') as g: 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 index 037c524ab1b..69a8235ddd8 100644 --- 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 @@ -7,17 +7,23 @@ import os import sys +from enum import Enum, auto + import colorama import azure.cli.core.telemetry as telemetry from knack.log import get_logger -from knack.util import CLIError # pylint: disable=unused-import +from knack.util import CLIError # pylint: disable=unused-import logger = get_logger(__name__) EXTENSION_DIR = os.path.dirname(os.path.realpath(__file__)) -RECOMMENDATION_FILE_PATH = os.path.join(EXTENSION_DIR, 'data/top_3_recommendations.json') +RECOMMENDATION_FILE_PATH = os.path.join( + EXTENSION_DIR, + 'data/top_3_recommendations.json' +) + RECOMMENDATIONS = None UPDATE_RECOMMENDATION_STR = ( @@ -28,6 +34,7 @@ with open(RECOMMENDATION_FILE_PATH) as recommendation_file: RECOMMENDATIONS = json.load(recommendation_file) + def style_message(msg): if should_enable_styling(): try: @@ -45,15 +52,18 @@ def should_enable_styling(): pass return False + # Commands def show_extension_version(): print(f'Current version: 0.1.0') + def get_values(comma_separated_values): if not comma_separated_values: return [] return comma_separated_values.split(',') + def parse_recommendation(recommendation): success_command = recommendation['SuccessCommand'] success_command_parameters = recommendation['SuccessCommand_Parameters'] @@ -67,25 +77,69 @@ def parse_recommendation(recommendation): return success_command, parameter_buffer, placeholder_buffer + def log_debug(msg): # TODO: see if there's a way to change the log formatter locally without printing to stdout prefix = '[Thoth]' logger.debug('%s: %s', prefix, msg) + +class RecommendationStatus(Enum): + RECOMMENDATIONS_AVAILABLE = auto() + NO_RECOMMENDATIONS_AVAILABLE = auto() + UNKNOWN_VERSION = auto() + + +def try_get_recommendations(version, command, parameters): + recommendations = RECOMMENDATIONS + status = RecommendationStatus.NO_RECOMMENDATIONS_AVAILABLE + + # if the specified CLI version doesn't have recommendations... + if version not in RECOMMENDATIONS: + # CLI version may be invalid or too old. + return RecommendationStatus.UNKNOWN_VERSION, None, None + + recommendations = recommendations[version] + + # if there are no recommendations for the specified command... + if command not in recommendations or not recommendations[command]: + return RecommendationStatus.NO_RECOMMENDATIONS_AVAILABLE, None, None + + recommendations = recommendations[command] + + # try getting a comma-separated parameter list + try: + parameters = ','.join(parameters) + # assume the parameters are already in the correct format. + except TypeError: + pass + + # use recommendations for a specific parameter set where applicable. + parameters = parameters if parameters in recommendations else '' + + # if there are no recommendations for the specified parameters... + if parameters in recommendations and recommendations[parameters]: + status = RecommendationStatus.RECOMMENDATIONS_AVAILABLE + recommendations = recommendations[parameters] + + # return status and processed list of parameters + return status, parameters, recommendations + + def recommend_recovery_options(version, command, parameters, extension): - recommendations = [] + 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 + 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): - recommendations.append(line) + result.append(line) def unable_to_help(command): append(f'\nSorry I am not able to help with [{command}]' @@ -96,32 +150,26 @@ def unable_to_help(command): if not command: log_debug('Command is empty. No action to perform.') - if extension is None and command: - if version in RECOMMENDATIONS: - command_recommendations = RECOMMENDATIONS[version] - - if command in command_recommendations and command_recommendations[command]: - parameters = ','.join(parameters) - parameters = parameters if parameters in command_recommendations[command] else '' + # if an extension is in-use or the command is empty... + if extension or not command: + return result - if parameters in command_recommendations[command]: - append(f'\nHere are the most common ways users succeeded after [{command}] failed:') + status, parameters, recommendations = try_get_recommendations(version, command, parameters) - top_recommendations = command_recommendations[command][parameters] + if status == RecommendationStatus.RECOMMENDATIONS_AVAILABLE: + append(f'\nHere are the most common ways users succeeded after [{command}] failed:') - for top_recommendation in top_recommendations: - command, parameters, placeholders = parse_recommendation(top_recommendation) - parameter_and_argument_buffer = [] + for recommendation in recommendations: + command, parameters, placeholders = parse_recommendation(recommendation) + parameter_and_argument_buffer = [] - for pair in zip(parameters, placeholders): - parameter_and_argument_buffer.append(' '.join(pair)) + for pair in zip(parameters, placeholders): + parameter_and_argument_buffer.append(' '.join(pair)) - append(f"\taz {command} {' '.join(parameter_and_argument_buffer)}") - else: - unable_to_help(command) - else: - unable_to_help(command) - else: - append(style_message(UPDATE_RECOMMENDATION_STR)) + append(f"\taz {command} {' '.join(parameter_and_argument_buffer)}") + elif status == RecommendationStatus.NO_RECOMMENDATIONS_AVAILABLE: + unable_to_help(command) + elif status == RecommendationStatus.UNKNOWN_VERSION: + append(style_message(UPDATE_RECOMMENDATION_STR)) - return recommendations + return result 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 index 2dcf9bb68b3..99c0f28cd71 100644 --- 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 @@ -2,4 +2,4 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# ----------------------------------------------------------------------------- \ No newline at end of file +# ----------------------------------------------------------------------------- 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 index 2dcf9bb68b3..99c0f28cd71 100644 --- 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 @@ -2,4 +2,4 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# ----------------------------------------------------------------------------- \ 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 index 6cbcb30aae5..32d5f0a6d63 100644 --- 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 @@ -14,5 +14,6 @@ TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), '..')) + class AiDidYouMeanThisScenarioTest(unittest.TestCase): - pass \ No newline at end of file + pass diff --git a/src/ai-did-you-mean-this/setup.py b/src/ai-did-you-mean-this/setup.py index 5dbb681b42c..7409193fa7c 100644 --- a/src/ai-did-you-mean-this/setup.py +++ b/src/ai-did-you-mean-this/setup.py @@ -53,4 +53,4 @@ packages=find_packages(), install_requires=DEPENDENCIES, package_data={'azext_ai_did_you_mean_this': ['azext_metadata.json']}, -) \ No newline at end of file +) From f708094ac9cbc2e3c02771c09b4d9a1ceda96b54 Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Mon, 27 Apr 2020 19:42:43 -0700 Subject: [PATCH 10/33] Integration tests, async/sync cli version check, integrate service into extension. --- .../azext_ai_did_you_mean_this/__init__.py | 9 + .../_check_for_updates.py | 99 ++++++++ .../azext_ai_did_you_mean_this/_style.py | 21 ++ .../azext_ai_did_you_mean_this/custom.py | 215 ++++++++---------- .../failure_recovery_recommendation.py | 63 +++++ .../tests/latest/_mock.py | 9 + ...id_you_mean_this_aladdin_service_call.yaml | 44 ++++ ..._ai_did_you_mean_this_cli_is_outdated.yaml | 44 ++++ ...i_did_you_mean_this_cli_is_up_to_date.yaml | 44 ++++ .../test_ai_did_you_mean_this_scenario.py | 78 ++++++- .../azext_ai_did_you_mean_this/util.py | 27 +++ 11 files changed, 537 insertions(+), 116 deletions(-) create mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_check_for_updates.py create mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_style.py create mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/failure_recovery_recommendation.py create mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/_mock.py create mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call.yaml create mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_cli_is_outdated.yaml create mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_cli_is_up_to_date.yaml create mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/util.py 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 index 356f1234674..f61d8b0bf28 100644 --- 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 @@ -3,9 +3,12 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +import threading + from azure.cli.core import AzCommandsLoader from azext_ai_did_you_mean_this._help import helps # pylint: disable=unused-import +from azext_ai_did_you_mean_this._check_for_updates import is_cli_up_to_date def inject_functions_into_core(): @@ -14,6 +17,12 @@ def inject_functions_into_core(): AzCliCommandParser.recommendation_provider = recommend_recovery_options +def check_if_up_to_date_in_background(*args, **kwargs): + worker = threading.Thread(target=is_cli_up_to_date, args=args, kwargs=kwargs) + worker.daemon = True + worker.start() + + class AiDidYouMeanThisCommandsLoader(AzCommandsLoader): def __init__(self, cli_ctx=None): diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_check_for_updates.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_check_for_updates.py new file mode 100644 index 00000000000..74d5d73a21c --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_check_for_updates.py @@ -0,0 +1,99 @@ +import sys +import re +import threading +from enum import Enum, auto + +from knack.log import get_logger +from azure.cli.core.util import check_connectivity + +from azext_ai_did_you_mean_this.util import cached + +logger = get_logger(__name__) + +MODULE_MATCH_REGEX = r'{module}\s+\(([\d.]+)\)' + +_async_is_up_to_date = threading.Event() +_async_has_checked_for_updates = threading.Event() +_async_unknown_update_status = threading.Event() + + +def _get_latest_package_version_from_pypi(module): + from subprocess import check_output, STDOUT, CalledProcessError + + if not check_connectivity(max_retries=0): + return None + + try: + cmd = [sys.executable] + \ + f'-m pip search {module} -vv --disable-pip-version-check --no-cache-dir --retries 0'.split() + logger.debug('Running: %s', cmd) + log_output = check_output(cmd, stderr=STDOUT, universal_newlines=True) + pattern = MODULE_MATCH_REGEX.format(module=module) + matches = re.search(pattern, log_output) + return matches.group(1) if matches else None + except CalledProcessError: + pass + + return None + + +class CliStatus(Enum): + OUTDATED = auto() + UP_TO_DATE = auto() + UNKNOWN = auto() + + +def reset_cli_update_status(): + EVENTS = ( + _async_has_checked_for_updates, + _async_is_up_to_date, + _async_unknown_update_status + ) + + for event in EVENTS: + if event.is_set(): + event.clear() + + +@cached(cache_if=(CliStatus.OUTDATED, CliStatus.UP_TO_DATE)) +def is_cli_up_to_date(): + from distutils.version import LooseVersion + from azure.cli.core import __version__ + installed_version = LooseVersion(__version__) + latest_version = _get_latest_package_version_from_pypi('azure-cli-core') + + result = CliStatus.UNKNOWN + + if latest_version is None: + _async_unknown_update_status.set() + _async_has_checked_for_updates.set() + return result + + latest_version = LooseVersion(latest_version) + is_up_to_date = installed_version >= latest_version + if is_up_to_date: + _async_is_up_to_date.set() + else: + _async_is_up_to_date.clear() + _async_has_checked_for_updates.set() + result = CliStatus.UP_TO_DATE if is_up_to_date else CliStatus.OUTDATED + return result + + +@cached(cache_if=(CliStatus.OUTDATED, CliStatus.UP_TO_DATE)) +def async_is_cli_up_to_date(wait=False, timeout=None): + if _async_has_checked_for_updates.is_set(): + logger.debug('Already checked for updates.') + # if we should wait on checking if the cli is up to date... + if wait or timeout is not None: + # wait for at most timeout seconds. + _async_has_checked_for_updates.wait(timeout) + timed_out = not _async_has_checked_for_updates.is_set() + # otherwise, if the status is unknown or if the check for updates is incomplete + if _async_unknown_update_status.is_set() or timed_out: + if timed_out: + logger.debug('Check for CLI update status timed out.') + # indicate that the status of the CLI is unknown + return CliStatus.UNKNOWN + # if we've chekced for updates already, return the status retrieved by that check. + return CliStatus.UP_TO_DATE if _async_is_up_to_date.is_set() else CliStatus.OUTDATED 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..78aea199576 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_style.py @@ -0,0 +1,21 @@ +import sys + +import colorama + + +def style_message(msg): + if should_enable_styling(): + try: + msg = colorama.Style.BRIGHT + msg + colorama.Style.RESET_ALL + except KeyError: + pass + return msg + + +def should_enable_styling(): + try: + if 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/custom.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/custom.py index 69a8235ddd8..45d890a07b6 100644 --- 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 @@ -4,53 +4,38 @@ # -------------------------------------------------------------------------------------------- import json -import os -import sys +from http import HTTPStatus +from pkg_resources import parse_version -from enum import Enum, auto +import requests -import colorama - -import azure.cli.core.telemetry as telemetry +import azure.cli.core.telemetry as telemetry_core from knack.log import get_logger from knack.util import CLIError # pylint: disable=unused-import -logger = get_logger(__name__) - -EXTENSION_DIR = os.path.dirname(os.path.realpath(__file__)) -RECOMMENDATION_FILE_PATH = os.path.join( - EXTENSION_DIR, - 'data/top_3_recommendations.json' -) +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._check_for_updates import CliStatus, async_is_cli_up_to_date -RECOMMENDATIONS = None +logger = get_logger(__name__) UPDATE_RECOMMENDATION_STR = ( "Better failure recovery recommendations are available from the latest version of the CLI. " "Please update for the best experience.\n" ) -with open(RECOMMENDATION_FILE_PATH) as recommendation_file: - RECOMMENDATIONS = json.load(recommendation_file) - - -def style_message(msg): - if should_enable_styling(): - try: - msg = colorama.Style.BRIGHT + msg + colorama.Style.RESET_ALL - except KeyError: - pass - return msg +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:' +) -def should_enable_styling(): - try: - if sys.stdout.isatty(): - return True - except AttributeError: - pass - return False +CLI_UPDATE_STATUS_CHECK_TIMEOUT = 1 # seconds +CLI_CHECK_IF_UP_TO_DATE = False # Commands @@ -58,118 +43,120 @@ def show_extension_version(): print(f'Current version: 0.1.0') -def get_values(comma_separated_values): - if not comma_separated_values: - return [] - return comma_separated_values.split(',') - - -def parse_recommendation(recommendation): - success_command = recommendation['SuccessCommand'] - success_command_parameters = recommendation['SuccessCommand_Parameters'] - success_command_argument_placeholders = recommendation['SuccessCommand_ArgumentPlaceholders'] - - if not success_command_parameters: - success_command_argument_placeholders = '' - - parameter_buffer = get_values(success_command_parameters) - placeholder_buffer = get_values(success_command_argument_placeholders) - - return success_command, parameter_buffer, placeholder_buffer - - -def log_debug(msg): +def _log_debug(msg, *args, **kwargs): # TODO: see if there's a way to change the log formatter locally without printing to stdout - prefix = '[Thoth]' - logger.debug('%s: %s', prefix, msg) - - -class RecommendationStatus(Enum): - RECOMMENDATIONS_AVAILABLE = auto() - NO_RECOMMENDATIONS_AVAILABLE = auto() - UNKNOWN_VERSION = auto() - - -def try_get_recommendations(version, command, parameters): - recommendations = RECOMMENDATIONS - status = RecommendationStatus.NO_RECOMMENDATIONS_AVAILABLE - - # if the specified CLI version doesn't have recommendations... - if version not in RECOMMENDATIONS: - # CLI version may be invalid or too old. - return RecommendationStatus.UNKNOWN_VERSION, None, None + msg = f'[Thoth]: {msg}' + logger.debug(msg, *args, **kwargs) - recommendations = recommendations[version] - # if there are no recommendations for the specified command... - if command not in recommendations or not recommendations[command]: - return RecommendationStatus.NO_RECOMMENDATIONS_AVAILABLE, None, None - - recommendations = recommendations[command] - - # try getting a comma-separated parameter list - try: - parameters = ','.join(parameters) - # assume the parameters are already in the correct format. - except TypeError: - pass - - # use recommendations for a specific parameter set where applicable. - parameters = parameters if parameters in recommendations else '' - - # if there are no recommendations for the specified parameters... - if parameters in recommendations and recommendations[parameters]: - status = RecommendationStatus.RECOMMENDATIONS_AVAILABLE - recommendations = recommendations[parameters] - - # return status and processed list of parameters - return status, parameters, recommendations +def normalize_and_sort_parameters(parameters): + # When an error occurs, global parameters are not filtered out. Repeat that logic here. + # TODO: Consider moving this list to a connstant in azure.cli.core.commands + parameters = [param for param in parameters if param not in ['--debug', '--verbose']] + return ','.join(sorted(parameters)) def recommend_recovery_options(version, command, parameters, extension): result = [] + _log_debug('recommend_recovery_options: version: "%s", command: "%s", parameters: "%s", extension: "%s"', + version, command, parameters, extension) # if the command is empty... if not command: # try to get the raw command field from telemetry. - session = telemetry._session # pylint: disable=protected-access + session = telemetry_core._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.') + _log_debug(f'Setting command to [{command}] from telemtry.') def append(line): result.append(line) def unable_to_help(command): - append(f'\nSorry I am not able to help with [{command}]' - f'\nTry running [az find "{command}"] to see examples of [{command}] from other users.') + 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.') + _log_debug('Detected extension. No action to perform.') if not command: - log_debug('Command is empty. No action to perform.') + _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 - status, parameters, recommendations = try_get_recommendations(version, command, parameters) + parameters = normalize_and_sort_parameters(parameters) + response = call_aladdin_service(command, parameters, '2.3.1') - if status == RecommendationStatus.RECOMMENDATIONS_AVAILABLE: - append(f'\nHere are the most common ways users succeeded after [{command}] failed:') + if response.status_code == HTTPStatus.OK: + recommendations = get_recommendations_from_http_response(response) - for recommendation in recommendations: - command, parameters, placeholders = parse_recommendation(recommendation) - parameter_and_argument_buffer = [] + if recommendations: + show_recommendation_header(command) - for pair in zip(parameters, placeholders): - parameter_and_argument_buffer.append(' '.join(pair)) - - append(f"\taz {command} {' '.join(parameter_and_argument_buffer)}") - elif status == RecommendationStatus.NO_RECOMMENDATIONS_AVAILABLE: + for recommendation in recommendations: + append(f"\t{recommendation}") + else: + unable_to_help(command) + else: unable_to_help(command) - elif status == RecommendationStatus.UNKNOWN_VERSION: - append(style_message(UPDATE_RECOMMENDATION_STR)) + + if CLI_CHECK_IF_UP_TO_DATE: + if async_is_cli_up_to_date.cached: + _log_debug('Retrieving CLI update status from cache.') + else: + _log_debug('Retrieving CLI update status from PyPi') + + cli_status = async_is_cli_up_to_date(timeout=CLI_UPDATE_STATUS_CHECK_TIMEOUT) + + if cli_status == CliStatus.OUTDATED: + append(style_message(UPDATE_RECOMMENDATION_STR)) + else: + _log_debug('Skipping CLI version check.') return result + + +def get_recommendations_from_http_response(response): + recommendations = [] + + for suggestion in json.loads(response.content): + recommendations.append(FailureRecoveryRecommendation(suggestion)) + + return recommendations + + +def call_aladdin_service(command, parameters, core_version): + session_id = telemetry_core._session._get_base_properties()['Reserved.SessionId'] # pylint: disable=protected-access + subscription_id = telemetry_core._get_azure_subscription_id() # pylint: disable=protected-access + version = str(parse_version(core_version)) + + context = { + "sessionId": session_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'} + + response = requests.get( + api_url, + params={ + 'query': json.dumps(query), + 'clientType': 'AzureCli', + 'context': json.dumps(context) + }, + headers=headers) + + 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..824aa584583 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/failure_recovery_recommendation.py @@ -0,0 +1,63 @@ +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', '') + data['NumberOfPairs'] = data.get('NumberOfPairs', 0) + + self._command = data['SuccessCommand'] + self._parameters = data['SuccessCommand_Parameters'] + self._placeholders = data['SuccessCommand_ArgumentPlaceholders'] + self._number_of_pairs = data['NumberOfPairs'] + + 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 + + @property + def number_of_pairs(self): + return self._number_of_pairs + + @number_of_pairs.setter + def number_of_pairs(self, value): + self._number_of_pairs = 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)}" 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..fa9e6236c7e --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/_mock.py @@ -0,0 +1,9 @@ +MOCK_PIP_SEARCH_OUTPUT = ''' +Starting new HTTPS connection (1): pypi.org:443 +https://pypi.org:443 "POST /pypi HTTP/1.1" 200 2041 +azure-cli-core ({ver}) - Microsoft Azure Command-Line Tools Core Module +azure-core (1.4.0) - Microsoft Azure Core Library for Python +opal-azure-cli-core (2.0.70) - Microsoft Azure Command-Line Tools Core Module +''' + +MOCK_UUID = '00000000-0000-0000-0000-000000000000' diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call.yaml b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call.yaml new file mode 100644 index 00000000000..d8a517674eb --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call.yaml @@ -0,0 +1,44 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - python-requests/2.23.0 + method: GET + uri: https://app.aladdindev.microsoft.com/api/v1.0/suggestions?query=%7B%22command%22%3A+%22account%22%2C+%22parameters%22%3A+%22%22%7D&clientType=AzureCli&context=%7B%22sessionId%22%3A+%2200000000-0000-0000-0000-000000000000%22%2C+%22subscriptionId%22%3A+%2200000000-0000-0000-0000-000000000000%22%2C+%22versionNumber%22%3A+%222.3.1%22%7D + response: + body: + string: '[{"SuccessCommand":"account get-access-token","SuccessCommand_Parameters":"--output,--resource,--subscription","SuccessCommand_ArgumentPlaceholders":"json,{resource},00000000-0000-0000-0000-000000000000","NumberOfPairs":173},{"SuccessCommand":"account + list","SuccessCommand_Parameters":"","SuccessCommand_ArgumentPlaceholders":"{}","NumberOfPairs":50},{"SuccessCommand":"ad + signed-in-user show","SuccessCommand_Parameters":"--output","SuccessCommand_ArgumentPlaceholders":"json","NumberOfPairs":48}]' + headers: + content-length: + - '499' + content-type: + - application/json; charset=utf-8 + date: + - Mon, 27 Apr 2020 22:32:28 GMT + server: + - Kestrel + set-cookie: + - ARRAffinity=3c5a4fd8cf65c15694a0e005871dcde2afa78eabab0e0f0d5ce5a9bf59f48f2b;Path=/;HttpOnly;Domain=app.aladdindev.microsoft.com + transfer-encoding: + - chunked + vary: + - Accept-Encoding + x-content-type-options: + - nosniff + x-powered-by: + - ASP.NET + status: + code: 200 + message: OK +version: 1 diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_cli_is_outdated.yaml b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_cli_is_outdated.yaml new file mode 100644 index 00000000000..5ca91c81f83 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_cli_is_outdated.yaml @@ -0,0 +1,44 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - python-requests/2.23.0 + method: HEAD + uri: https://example.org/ + response: + body: + string: '' + headers: + accept-ranges: + - bytes + age: + - '575958' + cache-control: + - max-age=604800 + content-length: + - '1256' + content-type: + - text/html; charset=UTF-8 + date: + - Mon, 27 Apr 2020 22:32:28 GMT + etag: + - '"3147526947"' + expires: + - Mon, 04 May 2020 22:32:28 GMT + last-modified: + - Thu, 17 Oct 2019 07:18:26 GMT + server: + - ECS (sjc/4E71) + x-cache: + - HIT + status: + code: 200 + message: OK +version: 1 diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_cli_is_up_to_date.yaml b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_cli_is_up_to_date.yaml new file mode 100644 index 00000000000..5ca91c81f83 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_cli_is_up_to_date.yaml @@ -0,0 +1,44 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - python-requests/2.23.0 + method: HEAD + uri: https://example.org/ + response: + body: + string: '' + headers: + accept-ranges: + - bytes + age: + - '575958' + cache-control: + - max-age=604800 + content-length: + - '1256' + content-type: + - text/html; charset=UTF-8 + date: + - Mon, 27 Apr 2020 22:32:28 GMT + etag: + - '"3147526947"' + expires: + - Mon, 04 May 2020 22:32:28 GMT + last-modified: + - Thu, 17 Oct 2019 07:18:26 GMT + server: + - ECS (sjc/4E71) + x-cache: + - HIT + status: + code: 200 + message: OK +version: 1 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 index 32d5f0a6d63..fdc8f010239 100644 --- 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 @@ -7,13 +7,87 @@ import unittest import mock import requests +import json +from collections import defaultdict +from azext_ai_did_you_mean_this import check_if_up_to_date_in_background +from azext_ai_did_you_mean_this.custom import call_aladdin_service +from azext_ai_did_you_mean_this.failure_recovery_recommendation import FailureRecoveryRecommendation +from azext_ai_did_you_mean_this._check_for_updates import async_is_cli_up_to_date, is_cli_up_to_date, reset_cli_update_status, CliStatus +from azext_ai_did_you_mean_this.tests.latest._mock import MOCK_PIP_SEARCH_OUTPUT, MOCK_UUID from azure_devtools.scenario_tests import AllowLargeResponse from azure.cli.testsdk import (ScenarioTest, ResourceGroupPreparer) +from azure.cli.core import __version__ as core_version TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), '..')) -class AiDidYouMeanThisScenarioTest(unittest.TestCase): - pass +def get_mock_recommendations(): + recommendation_data = [ + { + "SuccessCommand": "account get-access-token", + "SuccessCommand_Parameters": "--output,--resource,--subscription", + "SuccessCommand_ArgumentPlaceholders": "json,{resource},00000000-0000-0000-0000-000000000000", + }, + { + "SuccessCommand": "account list", + "SuccessCommand_Parameters": "", + "SuccessCommand_ArgumentPlaceholders": "", + }, + { + "SuccessCommand": "ad signed-in-user show", + "SuccessCommand_Parameters": "--output", + "SuccessCommand_ArgumentPlaceholders": "json", + } + ] + + recommendations = [FailureRecoveryRecommendation(data) for data in recommendation_data] + return recommendations + + +def get_mock_query(): + return 'account', '' + + +def get_mock_context(): + return MOCK_UUID, MOCK_UUID, '2.3.1' + + +class AiDidYouMeanThisScenarioTest(ScenarioTest): + def test_ai_did_you_mean_this_aladdin_service_call(self): + session_id, subscription_id, version = get_mock_context() + + with mock.patch('azure.cli.core.telemetry._session._get_base_properties', return_value={'Reserved.SessionId': session_id}): + with mock.patch('azure.cli.core.telemetry._get_azure_subscription_id', return_value=subscription_id): + command, parameters = get_mock_query() + response = call_aladdin_service(command, parameters, version) + self.assertEqual(200, response.status_code) + + recommendations = list(FailureRecoveryRecommendation(suggestion) for suggestion in json.loads(response.content)) + expected_recommendations = get_mock_recommendations() + + for expected_recommendation, recommendation in zip(recommendations, expected_recommendations): + self.assertEqual(expected_recommendation.command, recommendation.command) + self.assertEqual(expected_recommendation.parameters, recommendation.parameters) + self.assertEqual(expected_recommendation.placeholders, recommendation.placeholders) + + def test_ai_did_you_mean_this_cli_is_up_to_date(self): + cmd_output = MOCK_PIP_SEARCH_OUTPUT.format(ver=core_version) + with mock.patch('subprocess.check_output', return_value=cmd_output): + check_if_up_to_date_in_background(use_cache=False) + cli_status = async_is_cli_up_to_date(wait=True, use_cache=False) + self.assertEqual(cli_status, CliStatus.UP_TO_DATE) + self.assertTrue(getattr(async_is_cli_up_to_date, 'cached')) + self.assertEqual(getattr(async_is_cli_up_to_date, 'cached_result'), cli_status) + + def test_ai_did_you_mean_this_cli_is_outdated(self): + latest_version = '3.0.0' + cmd_output = MOCK_PIP_SEARCH_OUTPUT.format(ver=latest_version) + with mock.patch('subprocess.check_output', return_value=cmd_output): + cli_status = is_cli_up_to_date(use_cache=False) + self.assertEqual(cli_status, CliStatus.OUTDATED) + + def tearDown(self): + # reset async indicators for whether the CLI is up to date. + reset_cli_update_status() diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/util.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/util.py new file mode 100644 index 00000000000..92673b2c554 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/util.py @@ -0,0 +1,27 @@ +from functools import wraps + + +def cached(cache_if=None): + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + use_cache = kwargs.pop('use_cache', True) + + if wrapper.cached and use_cache: + return func.cached_result + + value = func(*args, **kwargs) + should_cache = True + + if cache_if: + should_cache = value in cache_if + if should_cache: + wrapper.cached = True + wrapper.cached_result = value + + return value + + wrapper.cached = False + wrapper.cached_result = None + return wrapper + return decorator From f6b5d693abeec308d707fc2190eed0f4e5cb7f48 Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Wed, 29 Apr 2020 07:39:07 -0700 Subject: [PATCH 11/33] Don't capture NumberOfPairs, register command table callback, disable async update check, clean up integration and unit tests, rely only on pip for versioning. --- .../azext_ai_did_you_mean_this/__init__.py | 20 ++--- .../_check_for_updates.py | 47 ----------- .../azext_ai_did_you_mean_this/_cmd_table.py | 7 ++ .../azext_ai_did_you_mean_this/custom.py | 13 ++- .../failure_recovery_recommendation.py | 15 ++-- ...id_you_mean_this_aladdin_service_call.yaml | 2 +- ..._aladdin_service_call_invalid_version.yaml | 42 ++++++++++ ...an_this_arguments_required_user_fault.yaml | 44 ++++++++++ ..._ai_did_you_mean_this_cli_is_outdated.yaml | 44 ---------- ...i_did_you_mean_this_cli_is_up_to_date.yaml | 44 ---------- .../test_ai_did_you_mean_this_scenario.py | 81 +++++++++++++------ .../azext_ai_did_you_mean_this/util.py | 2 +- 12 files changed, 169 insertions(+), 192 deletions(-) create mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_cmd_table.py create mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call_invalid_version.yaml create mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_arguments_required_user_fault.yaml delete mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_cli_is_outdated.yaml delete mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_cli_is_up_to_date.yaml 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 index f61d8b0bf28..9c88c92455d 100644 --- 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 @@ -3,12 +3,14 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -import threading - 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._check_for_updates import is_cli_up_to_date +from azext_ai_did_you_mean_this._cmd_table import on_command_table_loaded def inject_functions_into_core(): @@ -17,12 +19,6 @@ def inject_functions_into_core(): AzCliCommandParser.recommendation_provider = recommend_recovery_options -def check_if_up_to_date_in_background(*args, **kwargs): - worker = threading.Thread(target=is_cli_up_to_date, args=args, kwargs=kwargs) - worker.daemon = True - worker.start() - - class AiDidYouMeanThisCommandsLoader(AzCommandsLoader): def __init__(self, cli_ctx=None): @@ -30,9 +26,9 @@ def __init__(self, cli_ctx=None): ai_did_you_mean_this_custom = CliCommandType( operations_tmpl='azext_ai_did_you_mean_this.custom#{}') - super(AiDidYouMeanThisCommandsLoader, self).__init__(cli_ctx=cli_ctx, - custom_command_type=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() def load_command_table(self, args): diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_check_for_updates.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_check_for_updates.py index 74d5d73a21c..ccf58eb45ff 100644 --- a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_check_for_updates.py +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_check_for_updates.py @@ -1,10 +1,8 @@ import sys import re -import threading from enum import Enum, auto from knack.log import get_logger -from azure.cli.core.util import check_connectivity from azext_ai_did_you_mean_this.util import cached @@ -12,17 +10,10 @@ MODULE_MATCH_REGEX = r'{module}\s+\(([\d.]+)\)' -_async_is_up_to_date = threading.Event() -_async_has_checked_for_updates = threading.Event() -_async_unknown_update_status = threading.Event() - def _get_latest_package_version_from_pypi(module): from subprocess import check_output, STDOUT, CalledProcessError - if not check_connectivity(max_retries=0): - return None - try: cmd = [sys.executable] + \ f'-m pip search {module} -vv --disable-pip-version-check --no-cache-dir --retries 0'.split() @@ -43,18 +34,6 @@ class CliStatus(Enum): UNKNOWN = auto() -def reset_cli_update_status(): - EVENTS = ( - _async_has_checked_for_updates, - _async_is_up_to_date, - _async_unknown_update_status - ) - - for event in EVENTS: - if event.is_set(): - event.clear() - - @cached(cache_if=(CliStatus.OUTDATED, CliStatus.UP_TO_DATE)) def is_cli_up_to_date(): from distutils.version import LooseVersion @@ -65,35 +44,9 @@ def is_cli_up_to_date(): result = CliStatus.UNKNOWN if latest_version is None: - _async_unknown_update_status.set() - _async_has_checked_for_updates.set() return result latest_version = LooseVersion(latest_version) is_up_to_date = installed_version >= latest_version - if is_up_to_date: - _async_is_up_to_date.set() - else: - _async_is_up_to_date.clear() - _async_has_checked_for_updates.set() result = CliStatus.UP_TO_DATE if is_up_to_date else CliStatus.OUTDATED return result - - -@cached(cache_if=(CliStatus.OUTDATED, CliStatus.UP_TO_DATE)) -def async_is_cli_up_to_date(wait=False, timeout=None): - if _async_has_checked_for_updates.is_set(): - logger.debug('Already checked for updates.') - # if we should wait on checking if the cli is up to date... - if wait or timeout is not None: - # wait for at most timeout seconds. - _async_has_checked_for_updates.wait(timeout) - timed_out = not _async_has_checked_for_updates.is_set() - # otherwise, if the status is unknown or if the check for updates is incomplete - if _async_unknown_update_status.is_set() or timed_out: - if timed_out: - logger.debug('Check for CLI update status timed out.') - # indicate that the status of the CLI is unknown - return CliStatus.UNKNOWN - # if we've chekced for updates already, return the status retrieved by that check. - return CliStatus.UP_TO_DATE if _async_is_up_to_date.is_set() else CliStatus.OUTDATED 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..b7c291c5fb5 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_cmd_table.py @@ -0,0 +1,7 @@ +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/custom.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/custom.py index 45d890a07b6..21433239993 100644 --- 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 @@ -16,7 +16,7 @@ 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._check_for_updates import CliStatus, async_is_cli_up_to_date +from azext_ai_did_you_mean_this._check_for_updates import CliStatus, is_cli_up_to_date logger = get_logger(__name__) @@ -34,7 +34,6 @@ '\nHere are the most common ways users succeeded after [{command}] failed:' ) -CLI_UPDATE_STATUS_CHECK_TIMEOUT = 1 # seconds CLI_CHECK_IF_UP_TO_DATE = False @@ -107,12 +106,7 @@ def show_recommendation_header(command): unable_to_help(command) if CLI_CHECK_IF_UP_TO_DATE: - if async_is_cli_up_to_date.cached: - _log_debug('Retrieving CLI update status from cache.') - else: - _log_debug('Retrieving CLI update status from PyPi') - - cli_status = async_is_cli_up_to_date(timeout=CLI_UPDATE_STATUS_CHECK_TIMEOUT) + cli_status = is_cli_up_to_date() if cli_status == CliStatus.OUTDATED: append(style_message(UPDATE_RECOMMENDATION_STR)) @@ -132,6 +126,9 @@ def get_recommendations_from_http_response(response): def call_aladdin_service(command, parameters, core_version): + _log_debug('call_aladdin_service: version: "%s", command: "%s", parameters: "%s"', + core_version, command, parameters) + session_id = telemetry_core._session._get_base_properties()['Reserved.SessionId'] # pylint: disable=protected-access subscription_id = telemetry_core._get_azure_subscription_id() # pylint: disable=protected-access version = str(parse_version(core_version)) 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 index 824aa584583..3f92e5d9a36 100644 --- 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 @@ -8,12 +8,10 @@ def __init__(self, data): data['SuccessCommand'] = data.get('SuccessCommand', '') data['SuccessCommand_Parameters'] = data.get('SuccessCommand_Parameters', '') data['SuccessCommand_ArgumentPlaceholders'] = data.get('SuccessCommand_ArgumentPlaceholders', '') - data['NumberOfPairs'] = data.get('NumberOfPairs', 0) self._command = data['SuccessCommand'] self._parameters = data['SuccessCommand_Parameters'] self._placeholders = data['SuccessCommand_ArgumentPlaceholders'] - self._number_of_pairs = data['NumberOfPairs'] for attr in ('_parameters', '_placeholders'): value = getattr(self, attr) @@ -46,14 +44,6 @@ def placeholders(self, value): assert_has_split_method('placeholders', value) self._placeholders = value - @property - def number_of_pairs(self): - return self._number_of_pairs - - @number_of_pairs.setter - def number_of_pairs(self, value): - self._number_of_pairs = value - def __str__(self): parameter_and_argument_buffer = [] @@ -61,3 +51,8 @@ def __str__(self): 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/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call.yaml b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call.yaml index d8a517674eb..e37d94f11eb 100644 --- a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call.yaml +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call.yaml @@ -25,7 +25,7 @@ interactions: content-type: - application/json; charset=utf-8 date: - - Mon, 27 Apr 2020 22:32:28 GMT + - Wed, 29 Apr 2020 05:44:29 GMT server: - Kestrel set-cookie: diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call_invalid_version.yaml b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call_invalid_version.yaml new file mode 100644 index 00000000000..41921f86354 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call_invalid_version.yaml @@ -0,0 +1,42 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - python-requests/2.23.0 + method: GET + uri: https://app.aladdindev.microsoft.com/api/v1.0/suggestions?query=%7B%22command%22%3A+%22account%22%2C+%22parameters%22%3A+%22%22%7D&clientType=AzureCli&context=%7B%22sessionId%22%3A+%2200000000-0000-0000-0000-000000000000%22%2C+%22subscriptionId%22%3A+%2200000000-0000-0000-0000-000000000000%22%2C+%22versionNumber%22%3A+%223.5.0%22%7D + response: + body: + string: '[]' + headers: + content-length: + - '2' + content-type: + - application/json; charset=utf-8 + date: + - Wed, 29 Apr 2020 05:44:28 GMT + server: + - Kestrel + set-cookie: + - ARRAffinity=3c5a4fd8cf65c15694a0e005871dcde2afa78eabab0e0f0d5ce5a9bf59f48f2b;Path=/;HttpOnly;Domain=app.aladdindev.microsoft.com + transfer-encoding: + - chunked + vary: + - Accept-Encoding + x-content-type-options: + - nosniff + x-powered-by: + - ASP.NET + status: + code: 200 + message: OK +version: 1 diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_arguments_required_user_fault.yaml b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_arguments_required_user_fault.yaml new file mode 100644 index 00000000000..53bb64525fc --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_arguments_required_user_fault.yaml @@ -0,0 +1,44 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - python-requests/2.23.0 + method: GET + uri: https://app.aladdindev.microsoft.com/api/v1.0/suggestions?query=%7B%22command%22%3A+%22account%22%2C+%22parameters%22%3A+%22%22%7D&clientType=AzureCli&context=%7B%22sessionId%22%3A+%2200000000-0000-0000-0000-000000000000%22%2C+%22subscriptionId%22%3A+%2200000000-0000-0000-0000-000000000000%22%2C+%22versionNumber%22%3A+%222.3.1%22%7D + response: + body: + string: '[{"SuccessCommand":"account get-access-token","SuccessCommand_Parameters":"--output,--resource,--subscription","SuccessCommand_ArgumentPlaceholders":"json,{resource},00000000-0000-0000-0000-000000000000","NumberOfPairs":173},{"SuccessCommand":"account + list","SuccessCommand_Parameters":"","SuccessCommand_ArgumentPlaceholders":"{}","NumberOfPairs":50},{"SuccessCommand":"ad + signed-in-user show","SuccessCommand_Parameters":"--output","SuccessCommand_ArgumentPlaceholders":"json","NumberOfPairs":48}]' + headers: + content-length: + - '499' + content-type: + - application/json; charset=utf-8 + date: + - Wed, 29 Apr 2020 05:44:28 GMT + server: + - Kestrel + set-cookie: + - ARRAffinity=3c5a4fd8cf65c15694a0e005871dcde2afa78eabab0e0f0d5ce5a9bf59f48f2b;Path=/;HttpOnly;Domain=app.aladdindev.microsoft.com + transfer-encoding: + - chunked + vary: + - Accept-Encoding + x-content-type-options: + - nosniff + x-powered-by: + - ASP.NET + status: + code: 200 + message: OK +version: 1 diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_cli_is_outdated.yaml b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_cli_is_outdated.yaml deleted file mode 100644 index 5ca91c81f83..00000000000 --- a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_cli_is_outdated.yaml +++ /dev/null @@ -1,44 +0,0 @@ -interactions: -- request: - body: null - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Connection: - - keep-alive - User-Agent: - - python-requests/2.23.0 - method: HEAD - uri: https://example.org/ - response: - body: - string: '' - headers: - accept-ranges: - - bytes - age: - - '575958' - cache-control: - - max-age=604800 - content-length: - - '1256' - content-type: - - text/html; charset=UTF-8 - date: - - Mon, 27 Apr 2020 22:32:28 GMT - etag: - - '"3147526947"' - expires: - - Mon, 04 May 2020 22:32:28 GMT - last-modified: - - Thu, 17 Oct 2019 07:18:26 GMT - server: - - ECS (sjc/4E71) - x-cache: - - HIT - status: - code: 200 - message: OK -version: 1 diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_cli_is_up_to_date.yaml b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_cli_is_up_to_date.yaml deleted file mode 100644 index 5ca91c81f83..00000000000 --- a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_cli_is_up_to_date.yaml +++ /dev/null @@ -1,44 +0,0 @@ -interactions: -- request: - body: null - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Connection: - - keep-alive - User-Agent: - - python-requests/2.23.0 - method: HEAD - uri: https://example.org/ - response: - body: - string: '' - headers: - accept-ranges: - - bytes - age: - - '575958' - cache-control: - - max-age=604800 - content-length: - - '1256' - content-type: - - text/html; charset=UTF-8 - date: - - Mon, 27 Apr 2020 22:32:28 GMT - etag: - - '"3147526947"' - expires: - - Mon, 04 May 2020 22:32:28 GMT - last-modified: - - Thu, 17 Oct 2019 07:18:26 GMT - server: - - ECS (sjc/4E71) - x-cache: - - HIT - status: - code: 200 - message: OK -version: 1 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 index fdc8f010239..276f97e4933 100644 --- 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 @@ -10,10 +10,18 @@ import json from collections import defaultdict -from azext_ai_did_you_mean_this import check_if_up_to_date_in_background -from azext_ai_did_you_mean_this.custom import call_aladdin_service +from azext_ai_did_you_mean_this.custom import ( + call_aladdin_service, + get_recommendations_from_http_response, + normalize_and_sort_parameters, + recommend_recovery_options +) from azext_ai_did_you_mean_this.failure_recovery_recommendation import FailureRecoveryRecommendation -from azext_ai_did_you_mean_this._check_for_updates import async_is_cli_up_to_date, is_cli_up_to_date, reset_cli_update_status, CliStatus +from azext_ai_did_you_mean_this._check_for_updates import ( + is_cli_up_to_date, + CliStatus, +) +from azext_ai_did_you_mean_this._cmd_table import CommandTable from azext_ai_did_you_mean_this.tests.latest._mock import MOCK_PIP_SEARCH_OUTPUT, MOCK_UUID from azure_devtools.scenario_tests import AllowLargeResponse from azure.cli.testsdk import (ScenarioTest, ResourceGroupPreparer) @@ -51,35 +59,47 @@ def get_mock_query(): def get_mock_context(): - return MOCK_UUID, MOCK_UUID, '2.3.1' + return MOCK_UUID, MOCK_UUID -class AiDidYouMeanThisScenarioTest(ScenarioTest): - def test_ai_did_you_mean_this_aladdin_service_call(self): - session_id, subscription_id, version = get_mock_context() +# based on https://stackoverflow.com/questions/57299968/python-how-to-reuse-a-mock-to-avoid-writing-mock-patch-multiple-times +class PatchMixin(): + def patch(self, target, **kwargs): + patch = mock.patch(target, **kwargs) + patch.start() + self.addCleanup(patch.stop) + - with mock.patch('azure.cli.core.telemetry._session._get_base_properties', return_value={'Reserved.SessionId': session_id}): - with mock.patch('azure.cli.core.telemetry._get_azure_subscription_id', return_value=subscription_id): - command, parameters = get_mock_query() - response = call_aladdin_service(command, parameters, version) - self.assertEqual(200, response.status_code) +class AiDidYouMeanThisScenarioTest(ScenarioTest, PatchMixin): + def setUp(self): + super().setUp() + session_id, subscription_id = get_mock_context() + self.patch('azure.cli.core.telemetry._session._get_base_properties', return_value={'Reserved.SessionId': session_id}) + self.patch('azure.cli.core.telemetry._get_azure_subscription_id', return_value=subscription_id) + + def test_ai_did_you_mean_this_aladdin_service_call(self): + command, parameters = get_mock_query() + version = '2.3.1' + response = call_aladdin_service(command, parameters, version) + self.assertEqual(200, response.status_code) - recommendations = list(FailureRecoveryRecommendation(suggestion) for suggestion in json.loads(response.content)) - expected_recommendations = get_mock_recommendations() + recommendations = get_recommendations_from_http_response(response) + expected_recommendations = get_mock_recommendations() + self.assertEquals(recommendations, expected_recommendations) - for expected_recommendation, recommendation in zip(recommendations, expected_recommendations): - self.assertEqual(expected_recommendation.command, recommendation.command) - self.assertEqual(expected_recommendation.parameters, recommendation.parameters) - self.assertEqual(expected_recommendation.placeholders, recommendation.placeholders) + def test_ai_did_you_mean_this_aladdin_service_call_invalid_version(self): + command, parameters = get_mock_query() + invalid_version = '3.5.0' + response = call_aladdin_service(command, parameters, invalid_version) + self.assertEqual(200, response.status_code) def test_ai_did_you_mean_this_cli_is_up_to_date(self): cmd_output = MOCK_PIP_SEARCH_OUTPUT.format(ver=core_version) with mock.patch('subprocess.check_output', return_value=cmd_output): - check_if_up_to_date_in_background(use_cache=False) - cli_status = async_is_cli_up_to_date(wait=True, use_cache=False) + cli_status = is_cli_up_to_date(use_cache=False) self.assertEqual(cli_status, CliStatus.UP_TO_DATE) - self.assertTrue(getattr(async_is_cli_up_to_date, 'cached')) - self.assertEqual(getattr(async_is_cli_up_to_date, 'cached_result'), cli_status) + self.assertTrue(getattr(is_cli_up_to_date, 'cached')) + self.assertEqual(getattr(is_cli_up_to_date, 'cached_result'), cli_status) def test_ai_did_you_mean_this_cli_is_outdated(self): latest_version = '3.0.0' @@ -88,6 +108,17 @@ def test_ai_did_you_mean_this_cli_is_outdated(self): cli_status = is_cli_up_to_date(use_cache=False) self.assertEqual(cli_status, CliStatus.OUTDATED) - def tearDown(self): - # reset async indicators for whether the CLI is up to date. - reset_cli_update_status() + def test_ai_did_you_mean_this_arguments_required_user_fault(self): + recommendation_buffer = [] + orig_func = recommend_recovery_options + + def hook_recommend_recovery_options(*args, **kwargs): + recommendation_buffer.extend(orig_func(*args, **kwargs)) + return recommendation_buffer + + with mock.patch('azext_ai_did_you_mean_this.custom.recommend_recovery_options', wraps=hook_recommend_recovery_options): + with self.assertRaises(SystemExit): + self.cmd('account') + + self.assertIsNotNone(CommandTable.CMD_TBL) + self.assertGreater(len(recommendation_buffer), 0) diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/util.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/util.py index 92673b2c554..bd3890a5c1e 100644 --- a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/util.py +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/util.py @@ -8,7 +8,7 @@ def wrapper(*args, **kwargs): use_cache = kwargs.pop('use_cache', True) if wrapper.cached and use_cache: - return func.cached_result + return wrapper.cached_result value = func(*args, **kwargs) should_cache = True From 9bf6db3766625d6f3d7a25bfc17f7c309035f681 Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Sun, 3 May 2020 00:43:19 -0700 Subject: [PATCH 12/33] Normalize parameters and remove invalid command tokens where possible. Add some tests to verify normalization. --- .../azext_ai_did_you_mean_this/_const.py | 16 ++++ .../azext_ai_did_you_mean_this/custom.py | 82 +++++++++++++------ ...id_you_mean_this_aladdin_service_call.yaml | 12 +-- ..._aladdin_service_call_invalid_version.yaml | 4 +- ...an_this_arguments_required_user_fault.yaml | 12 +-- ..._custom_normalize_and_sort_parameters.yaml | 44 ++++++++++ .../tests/latest/test_custom.py | 53 ++++++++++++ 7 files changed, 185 insertions(+), 38 deletions(-) create mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_const.py create mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_custom_normalize_and_sort_parameters.yaml create mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/test_custom.py 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..d86ada19d58 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_const.py @@ -0,0 +1,16 @@ + +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:' +) + +CLI_CHECK_IF_UP_TO_DATE = False 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 index 21433239993..6fdcc0dc10d 100644 --- 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 @@ -17,24 +17,15 @@ 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._check_for_updates import CliStatus, is_cli_up_to_date - -logger = get_logger(__name__) - -UPDATE_RECOMMENDATION_STR = ( - "Better failure recovery recommendations are available from the latest version of the CLI. " - "Please update for the best experience.\n" +from azext_ai_did_you_mean_this._const import ( + RECOMMENDATION_HEADER_FMT_STR, + UNABLE_TO_HELP_FMT_STR, + UPDATE_RECOMMENDATION_STR, + CLI_CHECK_IF_UP_TO_DATE ) +from azext_ai_did_you_mean_this._cmd_table import CommandTable -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:' -) - -CLI_CHECK_IF_UP_TO_DATE = False +logger = get_logger(__name__) # Commands @@ -48,11 +39,56 @@ def _log_debug(msg, *args, **kwargs): logger.debug(msg, *args, **kwargs) -def normalize_and_sort_parameters(parameters): - # When an error occurs, global parameters are not filtered out. Repeat that logic here. - # TODO: Consider moving this list to a connstant in azure.cli.core.commands - parameters = [param for param in parameters if param not in ['--debug', '--verbose']] - return ','.join(sorted(parameters)) +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 not az_cli_command and recurse: + last_delim_idx = command.rfind(' ') + _log_debug('Removing unknown token "%s" from command.', command[last_delim_idx + 1:]) + if last_delim_idx != -1: + 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): + if not parameters: + return '' + + # TODO: Avoid setting rules for global parameters manually. + rules = { + '-h': '--help', + '--only-show-errors': None, + '-o': '--output', + '--query': None + } + + parameter_set = set() + parameter_table, command = get_parameter_table(cmd_table, command) + + if parameter_table: + for argument in parameter_table.values(): + options = argument.type.settings['options_list'] + sorted_options = sorted(options, key=len, reverse=True) + standard_form = sorted_options[0] + + for option in sorted_options[1:]: + rules[option] = standard_form + + rules[standard_form] = None + + for parameter in parameters: + if parameter in rules: + normalized_form = rules[parameter] or parameter + if normalized_form: + parameter_set.add(normalized_form) + else: + _log_debug('"%s" is an invalid parameter for command "%s".', command, parameters) + else: + parameter_set = set(parameters) + + return ','.join(sorted(parameter_set)) def recommend_recovery_options(version, command, parameters, extension): @@ -89,7 +125,7 @@ def show_recommendation_header(command): if extension or not command: return result - parameters = normalize_and_sort_parameters(parameters) + parameters = normalize_and_sort_parameters(CommandTable.CMD_TBL, command, parameters) response = call_aladdin_service(command, parameters, '2.3.1') if response.status_code == HTTPStatus.OK: @@ -102,8 +138,6 @@ def show_recommendation_header(command): append(f"\t{recommendation}") else: unable_to_help(command) - else: - unable_to_help(command) if CLI_CHECK_IF_UP_TO_DATE: cli_status = is_cli_up_to_date() diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call.yaml b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call.yaml index e37d94f11eb..c6af39441fb 100644 --- a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call.yaml +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call.yaml @@ -16,20 +16,20 @@ interactions: uri: https://app.aladdindev.microsoft.com/api/v1.0/suggestions?query=%7B%22command%22%3A+%22account%22%2C+%22parameters%22%3A+%22%22%7D&clientType=AzureCli&context=%7B%22sessionId%22%3A+%2200000000-0000-0000-0000-000000000000%22%2C+%22subscriptionId%22%3A+%2200000000-0000-0000-0000-000000000000%22%2C+%22versionNumber%22%3A+%222.3.1%22%7D response: body: - string: '[{"SuccessCommand":"account get-access-token","SuccessCommand_Parameters":"--output,--resource,--subscription","SuccessCommand_ArgumentPlaceholders":"json,{resource},00000000-0000-0000-0000-000000000000","NumberOfPairs":173},{"SuccessCommand":"account - list","SuccessCommand_Parameters":"","SuccessCommand_ArgumentPlaceholders":"{}","NumberOfPairs":50},{"SuccessCommand":"ad - signed-in-user show","SuccessCommand_Parameters":"--output","SuccessCommand_ArgumentPlaceholders":"json","NumberOfPairs":48}]' + string: '[{"SuccessCommand":"account list","SuccessCommand_Parameters":"","SuccessCommand_ArgumentPlaceholders":"{}","NumberOfPairs":208},{"SuccessCommand":"account + show","SuccessCommand_Parameters":"","SuccessCommand_ArgumentPlaceholders":"{}","NumberOfPairs":149},{"SuccessCommand":"account + set","SuccessCommand_Parameters":"--subscription","SuccessCommand_ArgumentPlaceholders":"Subscription","NumberOfPairs":18}]' headers: content-length: - - '499' + - '407' content-type: - application/json; charset=utf-8 date: - - Wed, 29 Apr 2020 05:44:29 GMT + - Fri, 01 May 2020 23:00:23 GMT server: - Kestrel set-cookie: - - ARRAffinity=3c5a4fd8cf65c15694a0e005871dcde2afa78eabab0e0f0d5ce5a9bf59f48f2b;Path=/;HttpOnly;Domain=app.aladdindev.microsoft.com + - ARRAffinity=c89eb90dc83f78a8dfc590a5b382b05eb341e22a0b67a2081618ca8cf13e54f0;Path=/;HttpOnly;Domain=app.aladdindev.microsoft.com transfer-encoding: - chunked vary: diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call_invalid_version.yaml b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call_invalid_version.yaml index 41921f86354..4fb0bcfc1e0 100644 --- a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call_invalid_version.yaml +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call_invalid_version.yaml @@ -23,11 +23,11 @@ interactions: content-type: - application/json; charset=utf-8 date: - - Wed, 29 Apr 2020 05:44:28 GMT + - Fri, 01 May 2020 23:00:23 GMT server: - Kestrel set-cookie: - - ARRAffinity=3c5a4fd8cf65c15694a0e005871dcde2afa78eabab0e0f0d5ce5a9bf59f48f2b;Path=/;HttpOnly;Domain=app.aladdindev.microsoft.com + - ARRAffinity=c89eb90dc83f78a8dfc590a5b382b05eb341e22a0b67a2081618ca8cf13e54f0;Path=/;HttpOnly;Domain=app.aladdindev.microsoft.com transfer-encoding: - chunked vary: diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_arguments_required_user_fault.yaml b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_arguments_required_user_fault.yaml index 53bb64525fc..441b08da2dd 100644 --- a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_arguments_required_user_fault.yaml +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_arguments_required_user_fault.yaml @@ -16,20 +16,20 @@ interactions: uri: https://app.aladdindev.microsoft.com/api/v1.0/suggestions?query=%7B%22command%22%3A+%22account%22%2C+%22parameters%22%3A+%22%22%7D&clientType=AzureCli&context=%7B%22sessionId%22%3A+%2200000000-0000-0000-0000-000000000000%22%2C+%22subscriptionId%22%3A+%2200000000-0000-0000-0000-000000000000%22%2C+%22versionNumber%22%3A+%222.3.1%22%7D response: body: - string: '[{"SuccessCommand":"account get-access-token","SuccessCommand_Parameters":"--output,--resource,--subscription","SuccessCommand_ArgumentPlaceholders":"json,{resource},00000000-0000-0000-0000-000000000000","NumberOfPairs":173},{"SuccessCommand":"account - list","SuccessCommand_Parameters":"","SuccessCommand_ArgumentPlaceholders":"{}","NumberOfPairs":50},{"SuccessCommand":"ad - signed-in-user show","SuccessCommand_Parameters":"--output","SuccessCommand_ArgumentPlaceholders":"json","NumberOfPairs":48}]' + string: '[{"SuccessCommand":"account list","SuccessCommand_Parameters":"","SuccessCommand_ArgumentPlaceholders":"{}","NumberOfPairs":208},{"SuccessCommand":"account + show","SuccessCommand_Parameters":"","SuccessCommand_ArgumentPlaceholders":"{}","NumberOfPairs":149},{"SuccessCommand":"account + set","SuccessCommand_Parameters":"--subscription","SuccessCommand_ArgumentPlaceholders":"Subscription","NumberOfPairs":18}]' headers: content-length: - - '499' + - '407' content-type: - application/json; charset=utf-8 date: - - Wed, 29 Apr 2020 05:44:28 GMT + - Fri, 01 May 2020 23:00:24 GMT server: - Kestrel set-cookie: - - ARRAffinity=3c5a4fd8cf65c15694a0e005871dcde2afa78eabab0e0f0d5ce5a9bf59f48f2b;Path=/;HttpOnly;Domain=app.aladdindev.microsoft.com + - ARRAffinity=c89eb90dc83f78a8dfc590a5b382b05eb341e22a0b67a2081618ca8cf13e54f0;Path=/;HttpOnly;Domain=app.aladdindev.microsoft.com transfer-encoding: - chunked vary: diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_custom_normalize_and_sort_parameters.yaml b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_custom_normalize_and_sort_parameters.yaml new file mode 100644 index 00000000000..f9e5697f854 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_custom_normalize_and_sort_parameters.yaml @@ -0,0 +1,44 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - python-requests/2.23.0 + method: GET + uri: https://app.aladdindev.microsoft.com/api/v1.0/suggestions?query=%7B%22command%22%3A+%22account%22%2C+%22parameters%22%3A+%22%22%7D&clientType=AzureCli&context=%7B%22sessionId%22%3A+%22fa1dac3f-5cf2-42c4-ad7b-eeb8905343b8%22%2C+%22subscriptionId%22%3A+%227a860d30-9296-483c-9237-c6eaa66f4bda%22%2C+%22versionNumber%22%3A+%222.3.1%22%7D + response: + body: + string: '[{"SuccessCommand":"account list","SuccessCommand_Parameters":"","SuccessCommand_ArgumentPlaceholders":"{}","NumberOfPairs":208},{"SuccessCommand":"account + show","SuccessCommand_Parameters":"","SuccessCommand_ArgumentPlaceholders":"{}","NumberOfPairs":149},{"SuccessCommand":"account + set","SuccessCommand_Parameters":"--subscription","SuccessCommand_ArgumentPlaceholders":"Subscription","NumberOfPairs":18}]' + headers: + content-length: + - '407' + content-type: + - application/json; charset=utf-8 + date: + - Sat, 02 May 2020 22:52:05 GMT + server: + - Kestrel + set-cookie: + - ARRAffinity=c89eb90dc83f78a8dfc590a5b382b05eb341e22a0b67a2081618ca8cf13e54f0;Path=/;HttpOnly;Domain=app.aladdindev.microsoft.com + transfer-encoding: + - chunked + vary: + - Accept-Encoding + x-content-type-options: + - nosniff + x-powered-by: + - ASP.NET + status: + code: 200 + message: OK +version: 1 diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/test_custom.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/test_custom.py new file mode 100644 index 00000000000..ca6b0179378 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/test_custom.py @@ -0,0 +1,53 @@ +import unittest + +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 + + +class AiDidYouMeanThisCustomScenarioTest(unittest.TestCase): + + def setUp(self): + 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 + + args = ['vm', 'show', '--ids', 'foo'] + command = 'vm show' + + self.cli = DummyCli() + cli_ctx = self.cli.commands_loader.cli_ctx + self.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) + + cmd_loader = self.cli.invocation.commands_loader + cmd_loader.load_command_table(args) + cmd_loader.command_table = {command: cmd_loader.command_table[command]} + + cmd_loader.command_name = command + cli_ctx.invocation.data['command_string'] = command + + register_global_subscription_argument(cli_ctx) + register_ids_argument(cli_ctx) + + cli_ctx.raise_event(EVENT_INVOKER_PRE_LOAD_ARGUMENTS, commands_loader=cmd_loader) + cmd_loader.load_arguments(command) + 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) + + self.cmd_tbl = cmd_loader.command_table + self.cmd = command + self.parameters = ['-g', '--name', '-n', '--resource-group', '--subscription', 'invalid', '-o', '-h', '--help', '--debug', '--verbose'] + self.expected_parameters = ','.join(['--help', '--name', '--output', '--resource-group', '--subscription']) + + def test_custom_normalize_and_sort_parameters(self): + parameters = normalize_and_sort_parameters(self.cmd_tbl, self.cmd, self.parameters) + self.assertEqual(parameters, self.expected_parameters) + + def test_custom_normalize_and_sort_parameters_remove_invalid_command_token(self): + cmd_with_invalid_token = f'{self.cmd} oops' + parameters = normalize_and_sort_parameters(self.cmd_tbl, cmd_with_invalid_token, self.parameters) + self.assertEqual(parameters, self.expected_parameters) From 408a2618ddd0b9ab11a9633f0cfb28aff8aba006 Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Tue, 5 May 2020 00:52:37 -0700 Subject: [PATCH 13/33] Update tests to reflect current recommendation model. Update recordings. Expand command test suite to include a range of commands. --- .../azext_ai_did_you_mean_this/custom.py | 49 ++++--- .../tests/latest/_commands.py | 127 ++++++++++++++++++ ...id_you_mean_this_aladdin_service_call.yaml | 2 +- ..._aladdin_service_call_invalid_version.yaml | 2 +- ...an_this_arguments_required_user_fault.yaml | 2 +- ..._custom_normalize_and_sort_parameters.yaml | 44 ------ .../test_ai_did_you_mean_this_scenario.py | 14 +- .../tests/latest/test_custom.py | 72 ++++++---- 8 files changed, 215 insertions(+), 97 deletions(-) create mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/_commands.py delete mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_custom_normalize_and_sort_parameters.yaml 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 index 6fdcc0dc10d..71d60685a02 100644 --- 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 @@ -53,6 +53,9 @@ def get_parameter_table(cmd_table, command, recurse=True): 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) + if not parameters: return '' @@ -61,37 +64,50 @@ def normalize_and_sort_parameters(cmd_table, command, parameters): '-h': '--help', '--only-show-errors': None, '-o': '--output', - '--query': None + '--query': None, + '--debug': None, + '--verbose': None } + blacklisted = {'--debug', '--verbose'} + parameter_set = set() parameter_table, command = get_parameter_table(cmd_table, command) if parameter_table: for argument in parameter_table.values(): options = argument.type.settings['options_list'] - sorted_options = sorted(options, key=len, reverse=True) - standard_form = sorted_options[0] + # remove deprecated arguments. + options = (option for option in options if not isinstance(option, Deprecated)) - for option in sorted_options[1:]: - rules[option] = standard_form + try: + sorted_options = sorted(options, key=len, reverse=True) + standard_form = sorted_options[0] - rules[standard_form] = None + for option in sorted_options[1:]: + rules[option] = standard_form - for parameter in parameters: - if parameter in rules: - normalized_form = rules[parameter] or parameter - if normalized_form: - parameter_set.add(normalized_form) - else: - _log_debug('"%s" is an invalid parameter for command "%s".', command, parameters) - else: - parameter_set = set(parameters) + rules[standard_form] = None + except TypeError: + _log_debug('Unexpected argument options `%s` of type `%s`.', options, type(options).__name__) + + for parameter in parameters: + if parameter in rules: + normalized_form = rules.get(parameter, None) or parameter + parameter_set.add(normalized_form) + else: + _log_debug('"%s" is an invalid parameter for command "%s".', parameter, command) + + parameter_set.difference_update(blacklisted) return ','.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 = [] _log_debug('recommend_recovery_options: version: "%s", command: "%s", parameters: "%s", extension: "%s"', version, command, parameters, extension) @@ -147,6 +163,9 @@ def show_recommendation_header(command): else: _log_debug('Skipping CLI version check.') + elapsed_time = timer() - start_time + _log_debug('The overall time it took to process failure recovery recommendations was %.2fms.', elapsed_time * 1000) + return result 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..2846b2ba4d4 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/_commands.py @@ -0,0 +1,127 @@ +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', '-n', '--no-wait', '--capacity'], + expected=['--location', '--resource-group', '--name', '--no-wait', '--capacity'] +) + + +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/recordings/test_ai_did_you_mean_this_aladdin_service_call.yaml b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call.yaml index c6af39441fb..99765f1a71d 100644 --- a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call.yaml +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call.yaml @@ -25,7 +25,7 @@ interactions: content-type: - application/json; charset=utf-8 date: - - Fri, 01 May 2020 23:00:23 GMT + - Tue, 05 May 2020 07:42:40 GMT server: - Kestrel set-cookie: diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call_invalid_version.yaml b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call_invalid_version.yaml index 4fb0bcfc1e0..138d66af0a9 100644 --- a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call_invalid_version.yaml +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call_invalid_version.yaml @@ -23,7 +23,7 @@ interactions: content-type: - application/json; charset=utf-8 date: - - Fri, 01 May 2020 23:00:23 GMT + - Tue, 05 May 2020 07:42:40 GMT server: - Kestrel set-cookie: diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_arguments_required_user_fault.yaml b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_arguments_required_user_fault.yaml index 441b08da2dd..297b858ffe7 100644 --- a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_arguments_required_user_fault.yaml +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_arguments_required_user_fault.yaml @@ -25,7 +25,7 @@ interactions: content-type: - application/json; charset=utf-8 date: - - Fri, 01 May 2020 23:00:24 GMT + - Tue, 05 May 2020 07:42:41 GMT server: - Kestrel set-cookie: diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_custom_normalize_and_sort_parameters.yaml b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_custom_normalize_and_sort_parameters.yaml deleted file mode 100644 index f9e5697f854..00000000000 --- a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_custom_normalize_and_sort_parameters.yaml +++ /dev/null @@ -1,44 +0,0 @@ -interactions: -- request: - body: null - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.23.0 - method: GET - uri: https://app.aladdindev.microsoft.com/api/v1.0/suggestions?query=%7B%22command%22%3A+%22account%22%2C+%22parameters%22%3A+%22%22%7D&clientType=AzureCli&context=%7B%22sessionId%22%3A+%22fa1dac3f-5cf2-42c4-ad7b-eeb8905343b8%22%2C+%22subscriptionId%22%3A+%227a860d30-9296-483c-9237-c6eaa66f4bda%22%2C+%22versionNumber%22%3A+%222.3.1%22%7D - response: - body: - string: '[{"SuccessCommand":"account list","SuccessCommand_Parameters":"","SuccessCommand_ArgumentPlaceholders":"{}","NumberOfPairs":208},{"SuccessCommand":"account - show","SuccessCommand_Parameters":"","SuccessCommand_ArgumentPlaceholders":"{}","NumberOfPairs":149},{"SuccessCommand":"account - set","SuccessCommand_Parameters":"--subscription","SuccessCommand_ArgumentPlaceholders":"Subscription","NumberOfPairs":18}]' - headers: - content-length: - - '407' - content-type: - - application/json; charset=utf-8 - date: - - Sat, 02 May 2020 22:52:05 GMT - server: - - Kestrel - set-cookie: - - ARRAffinity=c89eb90dc83f78a8dfc590a5b382b05eb341e22a0b67a2081618ca8cf13e54f0;Path=/;HttpOnly;Domain=app.aladdindev.microsoft.com - transfer-encoding: - - chunked - vary: - - Accept-Encoding - x-content-type-options: - - nosniff - x-powered-by: - - ASP.NET - status: - code: 200 - message: OK -version: 1 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 index 276f97e4933..e196fae9bdc 100644 --- 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 @@ -34,19 +34,19 @@ def get_mock_recommendations(): recommendation_data = [ { - "SuccessCommand": "account get-access-token", - "SuccessCommand_Parameters": "--output,--resource,--subscription", - "SuccessCommand_ArgumentPlaceholders": "json,{resource},00000000-0000-0000-0000-000000000000", + "SuccessCommand": "account list", + "SuccessCommand_Parameters": "", + "SuccessCommand_ArgumentPlaceholders": "", }, { - "SuccessCommand": "account list", + "SuccessCommand": "account show", "SuccessCommand_Parameters": "", "SuccessCommand_ArgumentPlaceholders": "", }, { - "SuccessCommand": "ad signed-in-user show", - "SuccessCommand_Parameters": "--output", - "SuccessCommand_ArgumentPlaceholders": "json", + "SuccessCommand": "account set", + "SuccessCommand_Parameters": "--subscription", + "SuccessCommand_ArgumentPlaceholders": "Subscription", } ] diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/test_custom.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/test_custom.py index ca6b0179378..7c7d47aa85c 100644 --- a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/test_custom.py +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/test_custom.py @@ -1,53 +1,69 @@ 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 AiDidYouMeanThisCustomScenarioTest(unittest.TestCase): - - def setUp(self): + @classmethod + def setUpClass(cls): 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 - args = ['vm', 'show', '--ids', 'foo'] - command = 'vm show' - - self.cli = DummyCli() - cli_ctx = self.cli.commands_loader.cli_ctx - self.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) - - cmd_loader = self.cli.invocation.commands_loader - cmd_loader.load_command_table(args) - cmd_loader.command_table = {command: cmd_loader.command_table[command]} - - cmd_loader.command_name = command - cli_ctx.invocation.data['command_string'] = command + # 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 the respective modules. + 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) - cmd_loader.load_arguments(command) + + # 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) - self.cmd_tbl = cmd_loader.command_table - self.cmd = command - self.parameters = ['-g', '--name', '-n', '--resource-group', '--subscription', 'invalid', '-o', '-h', '--help', '--debug', '--verbose'] - self.expected_parameters = ','.join(['--help', '--name', '--output', '--resource-group', '--subscription']) + cls.cmd_tbl = cmd_loader.command_table def test_custom_normalize_and_sort_parameters(self): - parameters = normalize_and_sort_parameters(self.cmd_tbl, self.cmd, self.parameters) - self.assertEqual(parameters, self.expected_parameters) + for cmd in AzCommandType: + parameters = normalize_and_sort_parameters(self.cmd_tbl, cmd.command, cmd.parameters) + self.assertEqual(parameters, cmd.expected_parameters) def test_custom_normalize_and_sort_parameters_remove_invalid_command_token(self): - cmd_with_invalid_token = f'{self.cmd} oops' - parameters = normalize_and_sort_parameters(self.cmd_tbl, cmd_with_invalid_token, self.parameters) - self.assertEqual(parameters, self.expected_parameters) + for cmd in AzCommandType: + cmd_with_invalid_token = f'{cmd.command} oops' + parameters = normalize_and_sort_parameters(self.cmd_tbl, cmd_with_invalid_token, cmd.parameters) + self.assertEqual(parameters, cmd.expected_parameters) + + def test_custom_normalize_and_sort_parameters_empty_parameter_list(self): + cmd = AzCommandType.ACCOUNT_SET + parameters = normalize_and_sort_parameters(self.cmd_tbl, cmd.command, '') + self.assertEqual(parameters, '') + + def test_custom_normalize_and_sort_parameters_invalid_command(self): + invalid_cmd = 'Lorem ipsum.' + parameters = normalize_and_sort_parameters(self.cmd_tbl, invalid_cmd, ['--foo', '--baz']) + self.assertEqual(parameters, '') From d0f65c0d128f303b539c9bd09cdc339595dc3f34 Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Wed, 6 May 2020 12:49:37 -0700 Subject: [PATCH 14/33] Switch to mock aladdin service call data, rename files, add mock recommendation model, add base class for aladdin test logic. --- .../_check_for_updates.py | 52 -------- .../azext_ai_did_you_mean_this/_const.py | 2 - .../azext_ai_did_you_mean_this/custom.py | 25 +--- .../tests/latest/_mock.py | 58 +++++++-- .../latest/aladdin_scenario_test_base.py | 88 +++++++++++++ .../tests/latest/input/recommendations.json | 19 +++ ...id_you_mean_this_aladdin_service_call.yaml | 44 ------- ..._aladdin_service_call_invalid_version.yaml | 42 ------- ...an_this_arguments_required_user_fault.yaml | 44 ------- .../test_ai_did_you_mean_this_scenario.py | 118 ++++-------------- ... => test_normalize_and_sort_parameters.py} | 4 +- 11 files changed, 189 insertions(+), 307 deletions(-) delete mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_check_for_updates.py create mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/aladdin_scenario_test_base.py create mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/input/recommendations.json delete mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call.yaml delete mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call_invalid_version.yaml delete mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_arguments_required_user_fault.yaml rename src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/{test_custom.py => test_normalize_and_sort_parameters.py} (96%) diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_check_for_updates.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_check_for_updates.py deleted file mode 100644 index ccf58eb45ff..00000000000 --- a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_check_for_updates.py +++ /dev/null @@ -1,52 +0,0 @@ -import sys -import re -from enum import Enum, auto - -from knack.log import get_logger - -from azext_ai_did_you_mean_this.util import cached - -logger = get_logger(__name__) - -MODULE_MATCH_REGEX = r'{module}\s+\(([\d.]+)\)' - - -def _get_latest_package_version_from_pypi(module): - from subprocess import check_output, STDOUT, CalledProcessError - - try: - cmd = [sys.executable] + \ - f'-m pip search {module} -vv --disable-pip-version-check --no-cache-dir --retries 0'.split() - logger.debug('Running: %s', cmd) - log_output = check_output(cmd, stderr=STDOUT, universal_newlines=True) - pattern = MODULE_MATCH_REGEX.format(module=module) - matches = re.search(pattern, log_output) - return matches.group(1) if matches else None - except CalledProcessError: - pass - - return None - - -class CliStatus(Enum): - OUTDATED = auto() - UP_TO_DATE = auto() - UNKNOWN = auto() - - -@cached(cache_if=(CliStatus.OUTDATED, CliStatus.UP_TO_DATE)) -def is_cli_up_to_date(): - from distutils.version import LooseVersion - from azure.cli.core import __version__ - installed_version = LooseVersion(__version__) - latest_version = _get_latest_package_version_from_pypi('azure-cli-core') - - result = CliStatus.UNKNOWN - - if latest_version is None: - return result - - latest_version = LooseVersion(latest_version) - is_up_to_date = installed_version >= latest_version - result = CliStatus.UP_TO_DATE if is_up_to_date else CliStatus.OUTDATED - return result 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 index d86ada19d58..76ec6669a3b 100644 --- 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 @@ -12,5 +12,3 @@ RECOMMENDATION_HEADER_FMT_STR = ( '\nHere are the most common ways users succeeded after [{command}] failed:' ) - -CLI_CHECK_IF_UP_TO_DATE = False 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 index 71d60685a02..2d119faf9a4 100644 --- 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 @@ -5,7 +5,6 @@ import json from http import HTTPStatus -from pkg_resources import parse_version import requests @@ -16,12 +15,9 @@ 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._check_for_updates import CliStatus, is_cli_up_to_date from azext_ai_did_you_mean_this._const import ( RECOMMENDATION_HEADER_FMT_STR, - UNABLE_TO_HELP_FMT_STR, - UPDATE_RECOMMENDATION_STR, - CLI_CHECK_IF_UP_TO_DATE + UNABLE_TO_HELP_FMT_STR ) from azext_ai_did_you_mean_this._cmd_table import CommandTable @@ -155,14 +151,6 @@ def show_recommendation_header(command): else: unable_to_help(command) - if CLI_CHECK_IF_UP_TO_DATE: - cli_status = is_cli_up_to_date() - - if cli_status == CliStatus.OUTDATED: - append(style_message(UPDATE_RECOMMENDATION_STR)) - else: - _log_debug('Skipping CLI version check.') - elapsed_time = timer() - start_time _log_debug('The overall time it took to process failure recovery recommendations was %.2fms.', elapsed_time * 1000) @@ -172,22 +160,21 @@ def show_recommendation_header(command): def get_recommendations_from_http_response(response): recommendations = [] - for suggestion in json.loads(response.content): + for suggestion in response.json(): recommendations.append(FailureRecoveryRecommendation(suggestion)) return recommendations -def call_aladdin_service(command, parameters, core_version): +def call_aladdin_service(command, parameters, version): _log_debug('call_aladdin_service: version: "%s", command: "%s", parameters: "%s"', - core_version, command, parameters) + version, command, parameters) - session_id = telemetry_core._session._get_base_properties()['Reserved.SessionId'] # pylint: disable=protected-access + correlation_id = telemetry_core._session.correlation_id # pylint: disable=protected-access subscription_id = telemetry_core._get_azure_subscription_id() # pylint: disable=protected-access - version = str(parse_version(core_version)) context = { - "sessionId": session_id, + "sessionId": correlation_id, "subscriptionId": subscription_id, "versionNumber": version } 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 index fa9e6236c7e..1bcdc118804 100644 --- 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 @@ -1,9 +1,53 @@ -MOCK_PIP_SEARCH_OUTPUT = ''' -Starting new HTTPS connection (1): pypi.org:443 -https://pypi.org:443 "POST /pypi HTTP/1.1" 200 2041 -azure-cli-core ({ver}) - Microsoft Azure Command-Line Tools Core Module -azure-core (1.4.0) - Microsoft Azure Core Library for Python -opal-azure-cli-core (2.0.70) - Microsoft Azure Command-Line Tools Core Module -''' +import os +import json +from http import HTTPStatus +from contextlib import contextmanager +import mock +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.3.1' + +# mock recommendation data constants +MOCK_INPUT_DIR = 'input' +MOCK_RECOMMENDATION_MODEL_FILENAME = 'recommendations.json' + + +def get_mock_recommendation_model_path(folder): + return os.path.join(folder, MOCK_INPUT_DIR, MOCK_RECOMMENDATION_MODEL_FILENAME) + + +class MockRecommendationModel(): + MODEL = None + + @classmethod + def load(cls, path): + with open(os.path.join(path), 'r') as test_recommendation_data_file: + cls.MODEL = json.load(test_recommendation_data_file) + + @classmethod + def create_mock_aladdin_service_http_response(cls, command): + mock_response = requests.Response() + mock_response.status_code = HTTPStatus.OK.value + data = cls.MODEL.get(command, []) + mock_response._content = bytes(json.dumps(data), 'utf-8') # pylint: disable=protected-access + return mock_response + + @classmethod + def get_recommendations(cls, command): + recommendations = cls.MODEL.get(command, []) + recommendations = [FailureRecoveryRecommendation(recommendation) for recommendation in recommendations] + return recommendations + + +@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..db5ad465c1c --- /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,88 @@ +import mock + +from azure_devtools.scenario_tests import mock_in_unit_test +from azure.cli.testsdk import ScenarioTest + +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 + +TELEMETRY_MODULE = 'azure.cli.core.telemetry' +TELEMETRY_SESSION_OBJECT = f'{TELEMETRY_MODULE}._session' + + +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) + + +class AladdinScenarioTest(ScenarioTest): + def __init__(self, method_name, **kwargs): + super().__init__(method_name, **kwargs) + + default_telemetry_patches = { + patch_ids, + patch_version + } + + self._exception = None + self._exit_code = 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): + func = recommend_recovery_options + + def _hook(*args, **kwargs): + result = func(*args, **kwargs) + self.recommendations.extend(result) + return result + + with mock.patch('azext_ai_did_you_mean_this.custom.recommend_recovery_options', wraps=_hook): + try: + super().cmd(command, checks=checks, expect_failure=expect_failure) + except SystemExit as ex: + self._exception = ex + self._exit_code = ex.code + + if expect_user_fault_failure: + self.assert_cmd_was_user_fault_failure() + else: + raise + + if expect_user_fault_failure: + self.assert_cmd_table_not_empty() + + 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) + + @property + def cli_version(self): + from azure.cli.core import __version__ as core_version + return core_version diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/input/recommendations.json b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/input/recommendations.json new file mode 100644 index 00000000000..8032fed3ec3 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/input/recommendations.json @@ -0,0 +1,19 @@ +{ + "account": [ + { + "SuccessCommand": "account list", + "SuccessCommand_Parameters": "", + "SuccessCommand_ArgumentPlaceholders": "" + }, + { + "SuccessCommand": "account show", + "SuccessCommand_Parameters": "", + "SuccessCommand_ArgumentPlaceholders": "" + }, + { + "SuccessCommand": "account set", + "SuccessCommand_Parameters": "--subscription", + "SuccessCommand_ArgumentPlaceholders": "Subscription" + } + ] +} \ No newline at end of file diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call.yaml b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call.yaml deleted file mode 100644 index 99765f1a71d..00000000000 --- a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call.yaml +++ /dev/null @@ -1,44 +0,0 @@ -interactions: -- request: - body: null - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.23.0 - method: GET - uri: https://app.aladdindev.microsoft.com/api/v1.0/suggestions?query=%7B%22command%22%3A+%22account%22%2C+%22parameters%22%3A+%22%22%7D&clientType=AzureCli&context=%7B%22sessionId%22%3A+%2200000000-0000-0000-0000-000000000000%22%2C+%22subscriptionId%22%3A+%2200000000-0000-0000-0000-000000000000%22%2C+%22versionNumber%22%3A+%222.3.1%22%7D - response: - body: - string: '[{"SuccessCommand":"account list","SuccessCommand_Parameters":"","SuccessCommand_ArgumentPlaceholders":"{}","NumberOfPairs":208},{"SuccessCommand":"account - show","SuccessCommand_Parameters":"","SuccessCommand_ArgumentPlaceholders":"{}","NumberOfPairs":149},{"SuccessCommand":"account - set","SuccessCommand_Parameters":"--subscription","SuccessCommand_ArgumentPlaceholders":"Subscription","NumberOfPairs":18}]' - headers: - content-length: - - '407' - content-type: - - application/json; charset=utf-8 - date: - - Tue, 05 May 2020 07:42:40 GMT - server: - - Kestrel - set-cookie: - - ARRAffinity=c89eb90dc83f78a8dfc590a5b382b05eb341e22a0b67a2081618ca8cf13e54f0;Path=/;HttpOnly;Domain=app.aladdindev.microsoft.com - transfer-encoding: - - chunked - vary: - - Accept-Encoding - x-content-type-options: - - nosniff - x-powered-by: - - ASP.NET - status: - code: 200 - message: OK -version: 1 diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call_invalid_version.yaml b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call_invalid_version.yaml deleted file mode 100644 index 138d66af0a9..00000000000 --- a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_aladdin_service_call_invalid_version.yaml +++ /dev/null @@ -1,42 +0,0 @@ -interactions: -- request: - body: null - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.23.0 - method: GET - uri: https://app.aladdindev.microsoft.com/api/v1.0/suggestions?query=%7B%22command%22%3A+%22account%22%2C+%22parameters%22%3A+%22%22%7D&clientType=AzureCli&context=%7B%22sessionId%22%3A+%2200000000-0000-0000-0000-000000000000%22%2C+%22subscriptionId%22%3A+%2200000000-0000-0000-0000-000000000000%22%2C+%22versionNumber%22%3A+%223.5.0%22%7D - response: - body: - string: '[]' - headers: - content-length: - - '2' - content-type: - - application/json; charset=utf-8 - date: - - Tue, 05 May 2020 07:42:40 GMT - server: - - Kestrel - set-cookie: - - ARRAffinity=c89eb90dc83f78a8dfc590a5b382b05eb341e22a0b67a2081618ca8cf13e54f0;Path=/;HttpOnly;Domain=app.aladdindev.microsoft.com - transfer-encoding: - - chunked - vary: - - Accept-Encoding - x-content-type-options: - - nosniff - x-powered-by: - - ASP.NET - status: - code: 200 - message: OK -version: 1 diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_arguments_required_user_fault.yaml b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_arguments_required_user_fault.yaml deleted file mode 100644 index 297b858ffe7..00000000000 --- a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/recordings/test_ai_did_you_mean_this_arguments_required_user_fault.yaml +++ /dev/null @@ -1,44 +0,0 @@ -interactions: -- request: - body: null - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.23.0 - method: GET - uri: https://app.aladdindev.microsoft.com/api/v1.0/suggestions?query=%7B%22command%22%3A+%22account%22%2C+%22parameters%22%3A+%22%22%7D&clientType=AzureCli&context=%7B%22sessionId%22%3A+%2200000000-0000-0000-0000-000000000000%22%2C+%22subscriptionId%22%3A+%2200000000-0000-0000-0000-000000000000%22%2C+%22versionNumber%22%3A+%222.3.1%22%7D - response: - body: - string: '[{"SuccessCommand":"account list","SuccessCommand_Parameters":"","SuccessCommand_ArgumentPlaceholders":"{}","NumberOfPairs":208},{"SuccessCommand":"account - show","SuccessCommand_Parameters":"","SuccessCommand_ArgumentPlaceholders":"{}","NumberOfPairs":149},{"SuccessCommand":"account - set","SuccessCommand_Parameters":"--subscription","SuccessCommand_ArgumentPlaceholders":"Subscription","NumberOfPairs":18}]' - headers: - content-length: - - '407' - content-type: - - application/json; charset=utf-8 - date: - - Tue, 05 May 2020 07:42:41 GMT - server: - - Kestrel - set-cookie: - - ARRAffinity=c89eb90dc83f78a8dfc590a5b382b05eb341e22a0b67a2081618ca8cf13e54f0;Path=/;HttpOnly;Domain=app.aladdindev.microsoft.com - transfer-encoding: - - chunked - vary: - - Accept-Encoding - x-content-type-options: - - nosniff - x-powered-by: - - ASP.NET - status: - code: 200 - message: OK -version: 1 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 index e196fae9bdc..c432af04d91 100644 --- 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 @@ -8,117 +8,43 @@ import mock import requests import json +from http import HTTPStatus from collections import defaultdict -from azext_ai_did_you_mean_this.custom import ( - call_aladdin_service, - get_recommendations_from_http_response, - normalize_and_sort_parameters, - recommend_recovery_options +from azext_ai_did_you_mean_this.custom import call_aladdin_service, get_recommendations_from_http_response +from azext_ai_did_you_mean_this.tests.latest._mock import ( + get_mock_recommendation_model_path, + mock_aladdin_service_call, + MockRecommendationModel ) -from azext_ai_did_you_mean_this.failure_recovery_recommendation import FailureRecoveryRecommendation -from azext_ai_did_you_mean_this._check_for_updates import ( - is_cli_up_to_date, - CliStatus, -) -from azext_ai_did_you_mean_this._cmd_table import CommandTable -from azext_ai_did_you_mean_this.tests.latest._mock import MOCK_PIP_SEARCH_OUTPUT, MOCK_UUID -from azure_devtools.scenario_tests import AllowLargeResponse -from azure.cli.testsdk import (ScenarioTest, ResourceGroupPreparer) - -from azure.cli.core import __version__ as core_version +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__), '..')) -def get_mock_recommendations(): - recommendation_data = [ - { - "SuccessCommand": "account list", - "SuccessCommand_Parameters": "", - "SuccessCommand_ArgumentPlaceholders": "", - }, - { - "SuccessCommand": "account show", - "SuccessCommand_Parameters": "", - "SuccessCommand_ArgumentPlaceholders": "", - }, - { - "SuccessCommand": "account set", - "SuccessCommand_Parameters": "--subscription", - "SuccessCommand_ArgumentPlaceholders": "Subscription", - } - ] - - recommendations = [FailureRecoveryRecommendation(data) for data in recommendation_data] - return recommendations - - -def get_mock_query(): - return 'account', '' - - -def get_mock_context(): - return MOCK_UUID, MOCK_UUID - +class AiDidYouMeanThisScenarioTest(AladdinScenarioTest): + @classmethod + def setUpClass(cls): + super().setUpClass() -# based on https://stackoverflow.com/questions/57299968/python-how-to-reuse-a-mock-to-avoid-writing-mock-patch-multiple-times -class PatchMixin(): - def patch(self, target, **kwargs): - patch = mock.patch(target, **kwargs) - patch.start() - self.addCleanup(patch.stop) + MockRecommendationModel.load(get_mock_recommendation_model_path(TEST_DIR)) - -class AiDidYouMeanThisScenarioTest(ScenarioTest, PatchMixin): def setUp(self): super().setUp() - session_id, subscription_id = get_mock_context() - self.patch('azure.cli.core.telemetry._session._get_base_properties', return_value={'Reserved.SessionId': session_id}) - self.patch('azure.cli.core.telemetry._get_azure_subscription_id', return_value=subscription_id) + + self.command, self.parameters = (AzCommandType.ACCOUNT.command, '') + self.invalid_cli_version = '0.0.0' def test_ai_did_you_mean_this_aladdin_service_call(self): - command, parameters = get_mock_query() - version = '2.3.1' - response = call_aladdin_service(command, parameters, version) - self.assertEqual(200, response.status_code) + with mock_aladdin_service_call(self.command): + response = call_aladdin_service(self.command, self.parameters, self.cli_version) + self.assertEqual(HTTPStatus.OK, response.status_code) recommendations = get_recommendations_from_http_response(response) - expected_recommendations = get_mock_recommendations() + expected_recommendations = MockRecommendationModel.get_recommendations(self.command) self.assertEquals(recommendations, expected_recommendations) - def test_ai_did_you_mean_this_aladdin_service_call_invalid_version(self): - command, parameters = get_mock_query() - invalid_version = '3.5.0' - response = call_aladdin_service(command, parameters, invalid_version) - self.assertEqual(200, response.status_code) - - def test_ai_did_you_mean_this_cli_is_up_to_date(self): - cmd_output = MOCK_PIP_SEARCH_OUTPUT.format(ver=core_version) - with mock.patch('subprocess.check_output', return_value=cmd_output): - cli_status = is_cli_up_to_date(use_cache=False) - self.assertEqual(cli_status, CliStatus.UP_TO_DATE) - self.assertTrue(getattr(is_cli_up_to_date, 'cached')) - self.assertEqual(getattr(is_cli_up_to_date, 'cached_result'), cli_status) - - def test_ai_did_you_mean_this_cli_is_outdated(self): - latest_version = '3.0.0' - cmd_output = MOCK_PIP_SEARCH_OUTPUT.format(ver=latest_version) - with mock.patch('subprocess.check_output', return_value=cmd_output): - cli_status = is_cli_up_to_date(use_cache=False) - self.assertEqual(cli_status, CliStatus.OUTDATED) - def test_ai_did_you_mean_this_arguments_required_user_fault(self): - recommendation_buffer = [] - orig_func = recommend_recovery_options - - def hook_recommend_recovery_options(*args, **kwargs): - recommendation_buffer.extend(orig_func(*args, **kwargs)) - return recommendation_buffer - - with mock.patch('azext_ai_did_you_mean_this.custom.recommend_recovery_options', wraps=hook_recommend_recovery_options): - with self.assertRaises(SystemExit): - self.cmd('account') - - self.assertIsNotNone(CommandTable.CMD_TBL) - self.assertGreater(len(recommendation_buffer), 0) + with mock_aladdin_service_call(self.command): + self.cmd(self.command, expect_user_fault_failure=True) diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/test_custom.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/test_normalize_and_sort_parameters.py similarity index 96% rename from src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/test_custom.py rename to src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/test_normalize_and_sort_parameters.py index 7c7d47aa85c..fae52141249 100644 --- a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/test_custom.py +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/test_normalize_and_sort_parameters.py @@ -8,9 +8,11 @@ from azext_ai_did_you_mean_this.tests.latest._commands import get_commands, AzCommandType -class AiDidYouMeanThisCustomScenarioTest(unittest.TestCase): +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 From 6fc9cd40e61520dec98fc165d38240c9e4c40773 Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Thu, 7 May 2020 12:54:10 -0700 Subject: [PATCH 15/33] Add copyright notice to all files, update minCliCoreVersion to 2.4.0, check if telemetry is enabled, add more comments, enable automated test support for user fault command failures and extension metadata, add mock recommendation model file. --- .../azext_ai_did_you_mean_this/_cmd_table.py | 6 + .../azext_ai_did_you_mean_this/_const.py | 8 ++ .../azext_ai_did_you_mean_this/_style.py | 5 + .../azext_metadata.json | 2 +- .../azext_ai_did_you_mean_this/custom.py | 129 +++++++++++------- .../failure_recovery_recommendation.py | 6 + .../tests/latest/_commands.py | 5 + .../tests/latest/_mock.py | 66 +++++++-- .../latest/aladdin_scenario_test_base.py | 84 ++++++++++-- .../tests/latest/input/recommendations.json | 19 --- .../tests/latest/model/recommendations.json | 76 +++++++++++ .../test_ai_did_you_mean_this_scenario.py | 50 +++++-- .../test_normalize_and_sort_parameters.py | 13 +- .../azext_ai_did_you_mean_this/util.py | 27 ---- 14 files changed, 362 insertions(+), 134 deletions(-) delete mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/input/recommendations.json create mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/model/recommendations.json delete mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/util.py 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 index b7c291c5fb5..9d77a5db9bd 100644 --- 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 @@ -1,3 +1,9 @@ +# -------------------------------------------------------------------------------------------- +# 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 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 index 76ec6669a3b..73406338462 100644 --- 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 @@ -1,3 +1,7 @@ +# -------------------------------------------------------------------------------------------- +# 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. " @@ -12,3 +16,7 @@ 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.' +) 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 index 78aea199576..ea041b707d4 100644 --- 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 @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + import sys import colorama 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 index 8cfc6da9485..6a44beb25b4 100644 --- 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 @@ -1,4 +1,4 @@ { "azext.isPreview": true, - "azext.minCliCoreVersion": "2.3.1" + "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/custom.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/custom.py index 2d119faf9a4..7a62764b0ab 100644 --- 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 @@ -8,7 +8,7 @@ import requests -import azure.cli.core.telemetry as telemetry_core +import azure.cli.core.telemetry as telemetry from knack.log import get_logger from knack.util import CLIError # pylint: disable=unused-import @@ -17,7 +17,8 @@ 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 + UNABLE_TO_HELP_FMT_STR, + TELEMETRY_MUST_BE_ENABLED_STR ) from azext_ai_did_you_mean_this._cmd_table import CommandTable @@ -25,6 +26,7 @@ # 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') @@ -39,10 +41,13 @@ 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(' ') - _log_debug('Removing unknown token "%s" from command.', command[last_delim_idx + 1:]) 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 @@ -52,51 +57,60 @@ 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) - if not parameters: - return '' - - # TODO: Avoid setting rules for global parameters manually. - rules = { - '-h': '--help', - '--only-show-errors': None, - '-o': '--output', - '--query': None, - '--debug': None, - '--verbose': None - } - - blacklisted = {'--debug', '--verbose'} - parameter_set = set() - parameter_table, command = get_parameter_table(cmd_table, command) - - 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)) - - try: - sorted_options = sorted(options, key=len, reverse=True) - standard_form = sorted_options[0] - - for option in sorted_options[1:]: - rules[option] = standard_form - rules[standard_form] = None - except TypeError: - _log_debug('Unexpected argument options `%s` of type `%s`.', options, type(options).__name__) - - for parameter in parameters: - if parameter in rules: - normalized_form = rules.get(parameter, None) or parameter - parameter_set.add(normalized_form) - else: - _log_debug('"%s" is an invalid parameter for command "%s".', parameter, command) - - parameter_set.difference_update(blacklisted) - - return ','.join(sorted(parameter_set)) + if parameters: + # TODO: Avoid setting rules for global parameters manually. + rules = { + '-h': '--help', + '--only-show-errors': None, + '-o': '--output', + '--query': None, + '--debug': None, + '--verbose': None + } + + blacklisted = {'--debug', '--verbose'} + + parameter_table, command = get_parameter_table(cmd_table, command) + + 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(blacklisted) + + # get the list of parameters as a comma-separated list + return command, ','.join(sorted(parameter_set)) def recommend_recovery_options(version, command, parameters, extension): @@ -105,13 +119,19 @@ def recommend_recovery_options(version, command, parameters, extension): 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_core._session # pylint: disable=protected-access + session = telemetry._session # pylint: disable=protected-access # get the raw command parsed by the CommandInvoker object. command = session.raw_command if command: @@ -137,9 +157,11 @@ def show_recommendation_header(command): if extension or not command: return result - parameters = normalize_and_sort_parameters(CommandTable.CMD_TBL, command, parameters) - response = call_aladdin_service(command, parameters, '2.3.1') + # 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.status_code == HTTPStatus.OK: recommendations = get_recommendations_from_http_response(response) @@ -148,7 +170,10 @@ def show_recommendation_header(command): for recommendation in recommendations: append(f"\t{recommendation}") - else: + # 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 @@ -170,8 +195,8 @@ def call_aladdin_service(command, parameters, version): _log_debug('call_aladdin_service: version: "%s", command: "%s", parameters: "%s"', version, command, parameters) - correlation_id = telemetry_core._session.correlation_id # pylint: disable=protected-access - subscription_id = telemetry_core._get_azure_subscription_id() # pylint: disable=protected-access + correlation_id = telemetry._session.correlation_id # pylint: disable=protected-access + subscription_id = telemetry._get_azure_subscription_id() # pylint: disable=protected-access context = { "sessionId": correlation_id, 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 index 3f92e5d9a36..e52c9cb567b 100644 --- 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 @@ -1,3 +1,9 @@ +# -------------------------------------------------------------------------------------------- +# 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') 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 index 2846b2ba4d4..c752f9df3da 100644 --- 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 @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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 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 index 1bcdc118804..9f1e134a621 100644 --- 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 @@ -1,6 +1,13 @@ +# -------------------------------------------------------------------------------------------- +# 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 +from enum import Enum, auto from http import HTTPStatus +from collections import namedtuple from contextlib import contextmanager import mock @@ -10,39 +17,82 @@ # mock service call context attributes MOCK_UUID = '00000000-0000-0000-0000-000000000000' -MOCK_VERSION = '2.3.1' +MOCK_VERSION = '2.4.0' # mock recommendation data constants -MOCK_INPUT_DIR = 'input' +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_INPUT_DIR, MOCK_RECOMMENDATION_MODEL_FILENAME) + 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: - cls.MODEL = json.load(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.MODEL.get(command, []) - mock_response._content = bytes(json.dumps(data), 'utf-8') # pylint: disable=protected-access + 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): - recommendations = cls.MODEL.get(command, []) - recommendations = [FailureRecoveryRecommendation(recommendation) for recommendation in recommendations] + 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): 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 index db5ad465c1c..237ade4265e 100644 --- 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 @@ -1,15 +1,36 @@ +# -------------------------------------------------------------------------------------------- +# 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 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 @@ -29,17 +50,27 @@ def patch_version(unit_test): 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_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 = [] @@ -51,27 +82,47 @@ def setUp(self): 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.extend(result) + self.recommendations = result return result - with mock.patch('azext_ai_did_you_mean_this.custom.recommend_recovery_options', wraps=_hook): - try: - super().cmd(command, checks=checks, expect_failure=expect_failure) - except SystemExit as ex: - self._exception = ex - self._exit_code = ex.code + 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: - self.assert_cmd_was_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: - raise + 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 @@ -82,7 +133,20 @@ def assert_cmd_was_user_fault_failure(self): 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/input/recommendations.json b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/input/recommendations.json deleted file mode 100644 index 8032fed3ec3..00000000000 --- a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/input/recommendations.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "account": [ - { - "SuccessCommand": "account list", - "SuccessCommand_Parameters": "", - "SuccessCommand_ArgumentPlaceholders": "" - }, - { - "SuccessCommand": "account show", - "SuccessCommand_Parameters": "", - "SuccessCommand_ArgumentPlaceholders": "" - }, - { - "SuccessCommand": "account set", - "SuccessCommand_Parameters": "--subscription", - "SuccessCommand_ArgumentPlaceholders": "Subscription" - } - ] -} \ No newline at end of file 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..dd277a6b37c --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/model/recommendations.json @@ -0,0 +1,76 @@ +{ + "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\"", + "NumberOfPairs": 11 + } + ], + "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 index c432af04d91..bc8860ee19b 100644 --- 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 @@ -12,10 +12,12 @@ from collections import defaultdict 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 + 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 @@ -29,22 +31,44 @@ 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() - self.command, self.parameters = (AzCommandType.ACCOUNT.command, '') - self.invalid_cli_version = '0.0.0' - def test_ai_did_you_mean_this_aladdin_service_call(self): - with mock_aladdin_service_call(self.command): - response = call_aladdin_service(self.command, self.parameters, self.cli_version) + 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 - self.assertEqual(HTTPStatus.OK, response.status_code) - recommendations = get_recommendations_from_http_response(response) - expected_recommendations = MockRecommendationModel.get_recommendations(self.command) - self.assertEquals(recommendations, expected_recommendations) + _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(_extension, entity.extension) - def test_ai_did_you_mean_this_arguments_required_user_fault(self): - with mock_aladdin_service_call(self.command): - self.cmd(self.command, expect_user_fault_failure=True) + 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 index fae52141249..bd74c686eb2 100644 --- 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 @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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 @@ -51,21 +56,21 @@ def setUpClass(cls): def test_custom_normalize_and_sort_parameters(self): for cmd in AzCommandType: - parameters = normalize_and_sort_parameters(self.cmd_tbl, cmd.command, cmd.parameters) + command, parameters = normalize_and_sort_parameters(self.cmd_tbl, cmd.command, cmd.parameters) self.assertEqual(parameters, cmd.expected_parameters) def test_custom_normalize_and_sort_parameters_remove_invalid_command_token(self): for cmd in AzCommandType: cmd_with_invalid_token = f'{cmd.command} oops' - parameters = normalize_and_sort_parameters(self.cmd_tbl, cmd_with_invalid_token, cmd.parameters) + command, parameters = normalize_and_sort_parameters(self.cmd_tbl, cmd_with_invalid_token, cmd.parameters) self.assertEqual(parameters, cmd.expected_parameters) def test_custom_normalize_and_sort_parameters_empty_parameter_list(self): cmd = AzCommandType.ACCOUNT_SET - parameters = normalize_and_sort_parameters(self.cmd_tbl, cmd.command, '') + command, parameters = normalize_and_sort_parameters(self.cmd_tbl, cmd.command, '') self.assertEqual(parameters, '') def test_custom_normalize_and_sort_parameters_invalid_command(self): invalid_cmd = 'Lorem ipsum.' - parameters = normalize_and_sort_parameters(self.cmd_tbl, invalid_cmd, ['--foo', '--baz']) + command, parameters = normalize_and_sort_parameters(self.cmd_tbl, invalid_cmd, ['--foo', '--baz']) self.assertEqual(parameters, '') diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/util.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/util.py deleted file mode 100644 index bd3890a5c1e..00000000000 --- a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/util.py +++ /dev/null @@ -1,27 +0,0 @@ -from functools import wraps - - -def cached(cache_if=None): - def decorator(func): - @wraps(func) - def wrapper(*args, **kwargs): - use_cache = kwargs.pop('use_cache', True) - - if wrapper.cached and use_cache: - return wrapper.cached_result - - value = func(*args, **kwargs) - should_cache = True - - if cache_if: - should_cache = value in cache_if - if should_cache: - wrapper.cached = True - wrapper.cached_result = value - - return value - - wrapper.cached = False - wrapper.cached_result = None - return wrapper - return decorator From ab8dfa3d50e22cf8c74bc716a060912ef536549c Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Thu, 7 May 2020 12:57:33 -0700 Subject: [PATCH 16/33] Assert that the correct command is returned from the normalize_and_sort_parameters function, remove number of pairs entry from mock recommendation file. --- .../tests/latest/model/recommendations.json | 3 +-- .../tests/latest/test_normalize_and_sort_parameters.py | 4 ++++ 2 files changed, 5 insertions(+), 2 deletions(-) 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 index dd277a6b37c..5359be372f9 100644 --- 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 @@ -66,8 +66,7 @@ { "SuccessCommand": "vm list", "SuccessCommand_Parameters": "--output,--query", - "SuccessCommand_ArgumentPlaceholders": "json,\"[].id\"", - "NumberOfPairs": 11 + "SuccessCommand_ArgumentPlaceholders": "json,\"[].id\"" } ], "arguments": "--query \".id\"", 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 index bd74c686eb2..a874c5d1f78 100644 --- 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 @@ -58,19 +58,23 @@ 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, '') + self.assertEqual(command, 'Lorem') From 28d01409f9c6fab149417e5cf1734ceb8edb6534 Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Thu, 7 May 2020 13:00:24 -0700 Subject: [PATCH 17/33] Add additional comments/clarification. --- .../tests/latest/test_normalize_and_sort_parameters.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index a874c5d1f78..06729b8b39c 100644 --- 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 @@ -29,7 +29,7 @@ def setUpClass(cls): parser_cls=cli_ctx.parser_cls, commands_loader_cls=cli_ctx.commands_loader_cls, help_cls=cli_ctx.help_cls) - # load command table for the respective modules. + # load command table for every module cmd_loader = cls.cli.invocation.commands_loader cmd_loader.load_command_table(None) @@ -77,4 +77,5 @@ 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') From edd4f86e7a7ae2a780fa22d982e6f1d478ce8fd1 Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Thu, 7 May 2020 13:08:09 -0700 Subject: [PATCH 18/33] Drop Python 2 support from setup.cfg --- src/ai-examples/setup.cfg | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/ai-examples/setup.cfg b/src/ai-examples/setup.cfg index 3c6e79cf31d..e69de29bb2d 100644 --- a/src/ai-examples/setup.cfg +++ b/src/ai-examples/setup.cfg @@ -1,2 +0,0 @@ -[bdist_wheel] -universal=1 From bee938b73721d32aca7c562b6e96279362430b73 Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Fri, 8 May 2020 11:41:36 -0700 Subject: [PATCH 19/33] Update to use standard library mock, disable colorama import error for static analysis, order imporrts better, remove deprecated parameters from `kusto cluster create` test case, fix global param rules. --- .../azext_ai_did_you_mean_this/_style.py | 2 +- .../azext_ai_did_you_mean_this/custom.py | 4 +++- .../azext_ai_did_you_mean_this/tests/latest/_commands.py | 4 ++-- .../azext_ai_did_you_mean_this/tests/latest/_mock.py | 2 +- .../tests/latest/aladdin_scenario_test_base.py | 2 +- .../tests/latest/test_ai_did_you_mean_this_scenario.py | 9 +++++++-- 6 files changed, 15 insertions(+), 8 deletions(-) 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 index ea041b707d4..64089ef6c77 100644 --- 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 @@ -5,7 +5,7 @@ import sys -import colorama +import colorama # pylint: disable=import-error def style_message(msg): 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 index 7a62764b0ab..f86719e02ee 100644 --- 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 @@ -63,8 +63,10 @@ def normalize_and_sort_parameters(cmd_table, command, parameters): # TODO: Avoid setting rules for global parameters manually. rules = { '-h': '--help', - '--only-show-errors': None, '-o': '--output', + '--only-show-errors': None, + '--help': None, + '--output': None, '--query': None, '--debug': None, '--verbose': None 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 index c752f9df3da..32f97e1a602 100644 --- 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 @@ -56,8 +56,8 @@ def get_expected_args(args): ) KUSTO_CLUSTER_CREATE_ARGS = Arguments( - actual=['-l', '-g', '-n', '--no-wait', '--capacity'], - expected=['--location', '--resource-group', '--name', '--no-wait', '--capacity'] + actual=['-l', '-g', '--no-wait'], + expected=['--location', '--resource-group', '--no-wait'] ) 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 index 9f1e134a621..9617c48d3ba 100644 --- 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 @@ -5,12 +5,12 @@ 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 mock import requests from azext_ai_did_you_mean_this.failure_recovery_recommendation import FailureRecoveryRecommendation 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 index 237ade4265e..ba8215b698e 100644 --- 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 @@ -5,7 +5,7 @@ import re import logging -import mock +import unittest.mock as mock from azure_devtools.scenario_tests import mock_in_unit_test from azure.cli.testsdk import ScenarioTest 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 index bc8860ee19b..7e49966fe7d 100644 --- 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 @@ -5,12 +5,13 @@ import os import unittest -import mock -import requests +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 ( @@ -51,6 +52,10 @@ def test_ai_did_you_mean_this_aladdin_service_call(self): def test_ai_did_you_mean_this_recommendations_for_user_fault_commands(self): for command, entity in self.test_cases: + self.kwargs.update({ + 'ext': entity.extension + }) + args = entity.arguments command_with_args = command if not args else f'{command} {args}' From 900569262da9bd287176405e0ab4c6eb3fbdb262 Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Fri, 8 May 2020 11:55:26 -0700 Subject: [PATCH 20/33] Make extension test more generic. --- .../tests/latest/test_ai_did_you_mean_this_scenario.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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 index 7e49966fe7d..fa9578d1583 100644 --- 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 @@ -52,10 +52,6 @@ def test_ai_did_you_mean_this_aladdin_service_call(self): def test_ai_did_you_mean_this_recommendations_for_user_fault_commands(self): for command, entity in self.test_cases: - self.kwargs.update({ - 'ext': entity.extension - }) - args = entity.arguments command_with_args = command if not args else f'{command} {args}' @@ -69,7 +65,7 @@ def test_ai_did_you_mean_this_recommendations_for_user_fault_commands(self): 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(_extension, entity.extension) + self.assertEqual(bool(_extension), bool(entity.extension)) if entity.recommendations: self.assert_recommendations_were_shown() From cf00711bc51a90b6aae29d263c9e87ff921514b8 Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Mon, 11 May 2020 10:49:11 -0700 Subject: [PATCH 21/33] Patch the correct setup.cfg file. --- src/ai-did-you-mean-this/setup.cfg | 2 -- src/ai-examples/setup.cfg | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ai-did-you-mean-this/setup.cfg b/src/ai-did-you-mean-this/setup.cfg index 3c6e79cf31d..e69de29bb2d 100644 --- a/src/ai-did-you-mean-this/setup.cfg +++ b/src/ai-did-you-mean-this/setup.cfg @@ -1,2 +0,0 @@ -[bdist_wheel] -universal=1 diff --git a/src/ai-examples/setup.cfg b/src/ai-examples/setup.cfg index e69de29bb2d..3c6e79cf31d 100644 --- a/src/ai-examples/setup.cfg +++ b/src/ai-examples/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=1 From 162a2efe0ceda8ec437e27ed5c94b135c89d2d89 Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Wed, 13 May 2020 12:27:59 -0700 Subject: [PATCH 22/33] Remove sensitive file, merge command group, handle exception from request module. --- src/ai-did-you-mean-this/README.md | 9 +++++++ src/ai-did-you-mean-this/README.rst | 6 ----- .../azext_ai_did_you_mean_this/commands.py | 5 +--- .../azext_ai_did_you_mean_this/custom.py | 24 ++++++++++++------- src/ai-did-you-mean-this/setup.py | 2 +- 5 files changed, 26 insertions(+), 20 deletions(-) create mode 100644 src/ai-did-you-mean-this/README.md delete mode 100644 src/ai-did-you-mean-this/README.rst 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..9490daed174 --- /dev/null +++ b/src/ai-did-you-mean-this/README.md @@ -0,0 +1,9 @@ +# 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 +``` + +### How to Use ### diff --git a/src/ai-did-you-mean-this/README.rst b/src/ai-did-you-mean-this/README.rst deleted file mode 100644 index 888b796e56e..00000000000 --- a/src/ai-did-you-mean-this/README.rst +++ /dev/null @@ -1,6 +0,0 @@ -Microsoft Azure CLI 'AI Did You Mean This' Extension -========================================== - -Improve user experience by suggesting recovery options for common CLI failures. - -This extension extends the default error handling behavior to include recommendations for recovery. Recommendations are based on how other users were successful after they encountered the same failure. \ 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 index cb59e4bcc0e..0732b3eba2c 100644 --- 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 @@ -8,8 +8,5 @@ def load_command_table(self, _): - with self.command_group('ai-did-you-mean-this') as g: + with self.command_group('ai-did-you-mean-this', is_preview=True) as g: g.custom_command('version', 'show_extension_version') - - with self.command_group('ai-did-you-mean-this', is_preview=True): - pass 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 index f86719e02ee..104d859ee6c 100644 --- 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 @@ -7,6 +7,7 @@ from http import HTTPStatus import requests +from requests import RequestException import azure.cli.core.telemetry as telemetry @@ -164,7 +165,7 @@ def show_recommendation_header(command): response = call_aladdin_service(command, parameters, version) # only show recommendations when we can contact the service. - if response.status_code == HTTPStatus.OK: + if response and response.status_code == HTTPStatus.OK: recommendations = get_recommendations_from_http_response(response) if recommendations: @@ -197,6 +198,8 @@ 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 @@ -214,13 +217,16 @@ def call_aladdin_service(command, parameters, version): api_url = 'https://app.aladdindev.microsoft.com/api/v1.0/suggestions' headers = {'Content-Type': 'application/json'} - response = requests.get( - api_url, - params={ - 'query': json.dumps(query), - 'clientType': 'AzureCli', - 'context': json.dumps(context) - }, - headers=headers) + 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) return response diff --git a/src/ai-did-you-mean-this/setup.py b/src/ai-did-you-mean-this/setup.py index 7409193fa7c..e13b5cc9222 100644 --- a/src/ai-did-you-mean-this/setup.py +++ b/src/ai-did-you-mean-this/setup.py @@ -33,7 +33,7 @@ DEPENDENCIES = [] -with open('README.rst', 'r', encoding='utf-8') as f: +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() From 955a1fc491cf46e60f1cf9a6b25dd566cdfd6586 Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Wed, 13 May 2020 14:05:22 -0700 Subject: [PATCH 23/33] Handle case in which the correlation ID or subscription ID is not set. --- .../azext_ai_did_you_mean_this/__init__.py | 3 +- .../azext_ai_did_you_mean_this/_cli_ctx.py | 12 +++ .../azext_ai_did_you_mean_this/_const.py | 20 +++++ .../azext_ai_did_you_mean_this/custom.py | 74 ++++++++++++------- 4 files changed, 82 insertions(+), 27 deletions(-) create mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_cli_ctx.py 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 index 9c88c92455d..ae81d58f121 100644 --- 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 @@ -11,6 +11,7 @@ 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 +from azext_ai_did_you_mean_this._cli_ctx import on_extension_loaded def inject_functions_into_core(): @@ -20,7 +21,6 @@ def inject_functions_into_core(): class AiDidYouMeanThisCommandsLoader(AzCommandsLoader): - def __init__(self, cli_ctx=None): from azure.cli.core.commands import CliCommandType @@ -29,6 +29,7 @@ def __init__(self, cli_ctx=None): 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) + on_extension_loaded(cli_ctx) inject_functions_into_core() def load_command_table(self, args): diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_cli_ctx.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_cli_ctx.py new file mode 100644 index 00000000000..8badfdb0c77 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_cli_ctx.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. +# -------------------------------------------------------------------------------------------- + + +class CliContext(): # pylint: disable=too-few-public-methods + CLI_CTX = None + + +def on_extension_loaded(cli_ctx): + CliContext.CLI_CTX = cli_ctx 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 index 73406338462..68643944724 100644 --- 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 @@ -20,3 +20,23 @@ 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. Trying another way..." +) + +TELEMETRY_MISSING_CORRELATION_ID_STR = ( + "Correlation ID was not set in telemetry." +) + +UNABLE_TO_RETRIEVE_SUBSCRIPTION_ID_STR = ( + "Unable to retrieve subscription ID." +) + +RETRIEVED_SUBSCRIPTION_ID_STR = ( + 'Retrieved subcription ID successfully.' +) + +UNABLE_TO_CALL_SERVICE_STR = ( + 'Either the subscription ID or correlation ID was not set. Aborting operation.' +) \ No newline at end of file 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 index 104d859ee6c..0bab767cae8 100644 --- 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 @@ -10,6 +10,7 @@ from requests import RequestException import azure.cli.core.telemetry as telemetry +from azure.cli.core.commands.client_factory import get_subscription_id from knack.log import get_logger from knack.util import CLIError # pylint: disable=unused-import @@ -19,9 +20,15 @@ from azext_ai_did_you_mean_this._const import ( RECOMMENDATION_HEADER_FMT_STR, UNABLE_TO_HELP_FMT_STR, - TELEMETRY_MUST_BE_ENABLED_STR + TELEMETRY_MUST_BE_ENABLED_STR, + TELEMETRY_MISSING_SUBSCRIPTION_ID_STR, + TELEMETRY_MISSING_CORRELATION_ID_STR, + UNABLE_TO_RETRIEVE_SUBSCRIPTION_ID_STR, + UNABLE_TO_CALL_SERVICE_STR, + RETRIEVED_SUBSCRIPTION_ID_STR ) from azext_ai_did_you_mean_this._cmd_table import CommandTable +from azext_ai_did_you_mean_this._cli_ctx import CliContext logger = get_logger(__name__) @@ -203,30 +210,45 @@ def call_aladdin_service(command, parameters, version): correlation_id = telemetry._session.correlation_id # pylint: disable=protected-access subscription_id = telemetry._get_azure_subscription_id() # pylint: disable=protected-access - 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) + if subscription_id is None: + _log_debug(TELEMETRY_MISSING_SUBSCRIPTION_ID_STR) + subscription_id = get_subscription_id(CliContext.CLI_CTX) + + if subscription_id is None: + _log_debug(UNABLE_TO_RETRIEVE_SUBSCRIPTION_ID_STR) + else: + _log_debug(RETRIEVED_SUBSCRIPTION_ID_STR) + + 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 correlation_id is None: + _log_debug(TELEMETRY_MISSING_CORRELATION_ID_STR) + + _log_debug(UNABLE_TO_CALL_SERVICE_STR) return response From 0a3eb00a7219130db895c5c2607e4daee8863a8f Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Wed, 13 May 2020 14:16:13 -0700 Subject: [PATCH 24/33] Don't use get_subscription_id. --- .../azext_ai_did_you_mean_this/__init__.py | 1 - .../azext_ai_did_you_mean_this/_cli_ctx.py | 12 ------------ .../azext_ai_did_you_mean_this/_const.py | 10 +--------- .../azext_ai_did_you_mean_this/custom.py | 16 +++------------- 4 files changed, 4 insertions(+), 35 deletions(-) delete mode 100644 src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_cli_ctx.py 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 index ae81d58f121..314604a9e20 100644 --- 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 @@ -29,7 +29,6 @@ def __init__(self, cli_ctx=None): 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) - on_extension_loaded(cli_ctx) inject_functions_into_core() def load_command_table(self, args): diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_cli_ctx.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_cli_ctx.py deleted file mode 100644 index 8badfdb0c77..00000000000 --- a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_cli_ctx.py +++ /dev/null @@ -1,12 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - - -class CliContext(): # pylint: disable=too-few-public-methods - CLI_CTX = None - - -def on_extension_loaded(cli_ctx): - CliContext.CLI_CTX = cli_ctx 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 index 68643944724..033b76b2c18 100644 --- 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 @@ -22,21 +22,13 @@ ) TELEMETRY_MISSING_SUBSCRIPTION_ID_STR = ( - "Subscription ID was not set in telemetry. Trying another way..." + "Subscription ID was not set in telemetry." ) TELEMETRY_MISSING_CORRELATION_ID_STR = ( "Correlation ID was not set in telemetry." ) -UNABLE_TO_RETRIEVE_SUBSCRIPTION_ID_STR = ( - "Unable to retrieve subscription ID." -) - -RETRIEVED_SUBSCRIPTION_ID_STR = ( - 'Retrieved subcription ID successfully.' -) - UNABLE_TO_CALL_SERVICE_STR = ( 'Either the subscription ID or correlation ID was not set. Aborting operation.' ) \ No newline at end of file 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 index 0bab767cae8..5e31de3a97b 100644 --- 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 @@ -10,7 +10,6 @@ from requests import RequestException import azure.cli.core.telemetry as telemetry -from azure.cli.core.commands.client_factory import get_subscription_id from knack.log import get_logger from knack.util import CLIError # pylint: disable=unused-import @@ -23,9 +22,7 @@ TELEMETRY_MUST_BE_ENABLED_STR, TELEMETRY_MISSING_SUBSCRIPTION_ID_STR, TELEMETRY_MISSING_CORRELATION_ID_STR, - UNABLE_TO_RETRIEVE_SUBSCRIPTION_ID_STR, - UNABLE_TO_CALL_SERVICE_STR, - RETRIEVED_SUBSCRIPTION_ID_STR + UNABLE_TO_CALL_SERVICE_STR ) from azext_ai_did_you_mean_this._cmd_table import CommandTable from azext_ai_did_you_mean_this._cli_ctx import CliContext @@ -210,15 +207,6 @@ def call_aladdin_service(command, parameters, version): correlation_id = telemetry._session.correlation_id # pylint: disable=protected-access subscription_id = telemetry._get_azure_subscription_id() # pylint: disable=protected-access - if subscription_id is None: - _log_debug(TELEMETRY_MISSING_SUBSCRIPTION_ID_STR) - subscription_id = get_subscription_id(CliContext.CLI_CTX) - - if subscription_id is None: - _log_debug(UNABLE_TO_RETRIEVE_SUBSCRIPTION_ID_STR) - else: - _log_debug(RETRIEVED_SUBSCRIPTION_ID_STR) - if subscription_id and correlation_id: context = { "sessionId": correlation_id, @@ -246,6 +234,8 @@ def call_aladdin_service(command, parameters, version): 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) From 1c806adedca0c75824d820386945e7da3dcca3ab Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Wed, 13 May 2020 14:16:55 -0700 Subject: [PATCH 25/33] Remove erroneous import. --- src/ai-did-you-mean-this/azext_ai_did_you_mean_this/__init__.py | 1 - 1 file changed, 1 deletion(-) 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 index 314604a9e20..74f1ada5557 100644 --- 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 @@ -11,7 +11,6 @@ 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 -from azext_ai_did_you_mean_this._cli_ctx import on_extension_loaded def inject_functions_into_core(): From d0819dbcce9f1dcfd0adb6268253eaa666d5ede0 Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Wed, 13 May 2020 14:17:51 -0700 Subject: [PATCH 26/33] Remove another erroneous import. --- src/ai-did-you-mean-this/azext_ai_did_you_mean_this/custom.py | 1 - 1 file changed, 1 deletion(-) 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 index 5e31de3a97b..04eb89e88d7 100644 --- 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 @@ -25,7 +25,6 @@ UNABLE_TO_CALL_SERVICE_STR ) from azext_ai_did_you_mean_this._cmd_table import CommandTable -from azext_ai_did_you_mean_this._cli_ctx import CliContext logger = get_logger(__name__) From c1eb46b56e3735f9068fd1b6fa8e741b8a1a2fff Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Wed, 13 May 2020 15:03:45 -0700 Subject: [PATCH 27/33] Add instructions for trying out an experimental release of the extension. --- src/ai-did-you-mean-this/README.md | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/ai-did-you-mean-this/README.md b/src/ai-did-you-mean-this/README.md index 9490daed174..a9b70e80262 100644 --- a/src/ai-did-you-mean-this/README.md +++ b/src/ai-did-you-mean-this/README.md @@ -6,4 +6,31 @@ To install the extension, use the below CLI command az extension add --name ai-did-you-mean-this ``` -### How to Use ### +### Try it out! ### + +### 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 preceding. + +#### 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 From 144ff522f29596df4592eee467c6e116c0e9f519 Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Wed, 13 May 2020 17:55:26 -0700 Subject: [PATCH 28/33] Added a detailed README, fixed bug in which invalid tokens were not removed unless there are no params. --- src/ai-did-you-mean-this/README.md | 59 ++++++++++++++++++- .../azext_ai_did_you_mean_this/custom.py | 3 +- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/ai-did-you-mean-this/README.md b/src/ai-did-you-mean-this/README.md index a9b70e80262..634db4ef845 100644 --- a/src/ai-did-you-mean-this/README.md +++ b/src/ai-did-you-mean-this/README.md @@ -6,10 +6,65 @@ 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 preceding. +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 @@ -24,7 +79,7 @@ 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 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} 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 index 04eb89e88d7..af0bf057963 100644 --- 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 @@ -62,6 +62,7 @@ def normalize_and_sort_parameters(cmd_table, command, parameters): _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. @@ -78,8 +79,6 @@ def normalize_and_sort_parameters(cmd_table, command, parameters): blacklisted = {'--debug', '--verbose'} - parameter_table, command = get_parameter_table(cmd_table, command) - if parameter_table: for argument in parameter_table.values(): options = argument.type.settings['options_list'] From 998277763e79c63c7edbe4ec06dcf7d90d65035e Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Wed, 13 May 2020 18:04:49 -0700 Subject: [PATCH 29/33] Add final newline to _const.py --- src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 033b76b2c18..238ec3151e6 100644 --- 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 @@ -31,4 +31,4 @@ UNABLE_TO_CALL_SERVICE_STR = ( 'Either the subscription ID or correlation ID was not set. Aborting operation.' -) \ No newline at end of file +) From 665e3fd1db36923054d18cba29f292165825722f Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Wed, 13 May 2020 18:24:58 -0700 Subject: [PATCH 30/33] Make styling check obey coloring configuration in core. --- .../azext_ai_did_you_mean_this/__init__.py | 10 ++++++++++ .../azext_ai_did_you_mean_this/_style.py | 6 ++++-- 2 files changed, 14 insertions(+), 2 deletions(-) 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 index 74f1ada5557..8db2b74fe04 100644 --- 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 @@ -19,6 +19,11 @@ def inject_functions_into_core(): 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 @@ -29,6 +34,11 @@ def __init__(self, cli_ctx=None): 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 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 index 64089ef6c77..f32e8452062 100644 --- 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 @@ -5,11 +5,13 @@ import sys -import colorama # pylint: disable=import-error +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: @@ -19,7 +21,7 @@ def style_message(msg): def should_enable_styling(): try: - if sys.stdout.isatty(): + if GlobalConfig.ENABLE_STYLING and sys.stdout.isatty(): return True except AttributeError: pass From 79ee91bc157089017560bbd37caae7fd1cad702c Mon Sep 17 00:00:00 2001 From: Jianhui Harold Date: Thu, 14 May 2020 10:21:41 +0800 Subject: [PATCH 31/33] Update src/ai-did-you-mean-this/setup.py --- src/ai-did-you-mean-this/setup.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ai-did-you-mean-this/setup.py b/src/ai-did-you-mean-this/setup.py index e13b5cc9222..f2054d9212b 100644 --- a/src/ai-did-you-mean-this/setup.py +++ b/src/ai-did-you-mean-this/setup.py @@ -24,7 +24,6 @@ 'Intended Audience :: System Administrators', 'Programming Language :: Python', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', From 1569869738e9c20c6ba9ac0303841e2369fb552e Mon Sep 17 00:00:00 2001 From: Jianhui Harold Date: Thu, 14 May 2020 10:21:54 +0800 Subject: [PATCH 32/33] Update src/ai-did-you-mean-this/setup.py --- src/ai-did-you-mean-this/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ai-did-you-mean-this/setup.py b/src/ai-did-you-mean-this/setup.py index f2054d9212b..dc93ac897f0 100644 --- a/src/ai-did-you-mean-this/setup.py +++ b/src/ai-did-you-mean-this/setup.py @@ -45,7 +45,7 @@ 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', + url='https://github.com/Azure/azure-cli-extensions/ai-did-you-mean-this', long_description=README + '\n\n' + HISTORY, license='MIT', classifiers=CLASSIFIERS, From f28c2562996f1a7c30d09d3fd26378ca05ab891c Mon Sep 17 00:00:00 2001 From: Christopher OToole Date: Thu, 14 May 2020 11:01:28 -0700 Subject: [PATCH 33/33] Changed variable name to avoid chromium issue 981129. --- src/ai-did-you-mean-this/azext_ai_did_you_mean_this/custom.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index af0bf057963..ede97b1c7e4 100644 --- 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 @@ -77,7 +77,7 @@ def normalize_and_sort_parameters(cmd_table, command, parameters): '--verbose': None } - blacklisted = {'--debug', '--verbose'} + blocklisted = {'--debug', '--verbose'} if parameter_table: for argument in parameter_table.values(): @@ -112,7 +112,7 @@ def normalize_and_sort_parameters(cmd_table, command, parameters): _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(blacklisted) + parameter_set.difference_update(blocklisted) # get the list of parameters as a comma-separated list return command, ','.join(sorted(parameter_set))