diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index c2b62d8b81d..4a98ce758fe 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -33,7 +33,7 @@ # [Reserved, in case of future usage] # Modules that will always be loaded. They don't expose commands but hook into CLI core. -ALWAYS_LOADED_MODULES = [] +ALWAYS_LOADED_MODULES = ['hint'] # Extensions that will always be loaded if installed. They don't expose commands but hook into CLI core. ALWAYS_LOADED_EXTENSIONS = ['azext_ai_examples', 'azext_next'] diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index 087557cf1c1..1e018868817 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -680,7 +680,8 @@ def execute(self, args): return CommandResultItem( event_data['result'], table_transformer=self.commands_loader.command_table[parsed_args.command].table_transformer, - is_query_active=self.data['query_active']) + is_query_active=self.data['query_active'], + raw_result=results) @staticmethod def _extract_parameter_names(args): diff --git a/src/azure-cli-core/azure/cli/core/tests/test_util.py b/src/azure-cli-core/azure/cli/core/tests/test_util.py index 544d21dd9ec..7f46d5a3471 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_util.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_util.py @@ -448,6 +448,16 @@ def test_get_parent_proc_name(self, mock_process_type): parent2.name.return_value = "bash" self.assertEqual(_get_parent_proc_name(), "pwsh") + def test_roughly_parse_command(self): + from azure.cli.core.util import roughly_parse_command + import shlex + self.assertEqual(roughly_parse_command(['login']), 'login') + self.assertEqual(roughly_parse_command(shlex.split('login --service-principal')), 'login') + self.assertEqual(roughly_parse_command(shlex.split('storage account --resource-group')), 'storage account') + self.assertEqual(roughly_parse_command(shlex.split('storage account -g')), 'storage account') + # Positional argument can't be distinguished + self.assertEqual(roughly_parse_command(shlex.split('config set output=table')), 'config set output=table') + class TestBase64ToHex(unittest.TestCase): diff --git a/src/azure-cli/azure/cli/command_modules/hint/__init__.py b/src/azure-cli/azure/cli/command_modules/hint/__init__.py new file mode 100644 index 00000000000..2ad01280787 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/hint/__init__.py @@ -0,0 +1,46 @@ +# -------------------------------------------------------------------------------------------- +# 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.command_modules.hint.custom import login_hinter, demo_hint_hinter +from azure.cli.core import AzCommandsLoader + +hinters = { + # The hinters are matched based on the order they appear in the dict + # https://docs.python.org/3/library/stdtypes.html#dict + # Changed in version 3.7: Dictionary order is guaranteed to be insertion order. + # This behavior was an implementation detail of CPython from 3.6. + 'login': login_hinter, + 'demo hint': demo_hint_hinter +} + + +class HintCommandsLoader(AzCommandsLoader): + + def __init__(self, cli_ctx=None): + from azure.cli.core.commands import CliCommandType + hint_custom = CliCommandType(operations_tmpl='azure.cli.command_modules.hint.custom#{}') + super(HintCommandsLoader, self).__init__(cli_ctx=cli_ctx, custom_command_type=hint_custom) + + def load_command_table(self, args): + self._register_hinters(args) + return self.command_table + + def _register_hinters(self, args): + import re + from knack.events import EVENT_CLI_SUCCESSFUL_EXECUTE + from knack.log import get_logger + logger = get_logger(__name__) + + from azure.cli.core.util import roughly_parse_command + command_line = roughly_parse_command(args) + for command_regex, hinter in hinters.items(): + if re.fullmatch(command_regex, command_line): + logger.debug("Registering hinter: %s: %s", command_regex, hinter.__name__) + self.cli_ctx.register_event(EVENT_CLI_SUCCESSFUL_EXECUTE, hinter) + # Improve if more than one hinters are needed + break + + +COMMAND_LOADER_CLS = HintCommandsLoader diff --git a/src/azure-cli/azure/cli/command_modules/hint/custom.py b/src/azure-cli/azure/cli/command_modules/hint/custom.py new file mode 100644 index 00000000000..7036ba6977d --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/hint/custom.py @@ -0,0 +1,103 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azure.cli.core.style import print_styled_text, Style +from azure.cli.core.decorators import suppress_all_exceptions + + +def _get_default_account_text(accounts): + """Return the type (tenant or subscription) and display text for the default account. + + - For tenant account, only show the tenant ID. + - For subscription account, if name can uniquely identify the account, only show the name; + Otherwise, show both name and ID. + """ + account = next(s for s in accounts if s['isDefault'] is True) + account_name = account['name'] + account_id = account['id'] + + # Tenant account + from azure.cli.core._profile import _TENANT_LEVEL_ACCOUNT_NAME + if account_name == _TENANT_LEVEL_ACCOUNT_NAME: + return "tenant", account_id + + # Subscription account + # Check if name can uniquely identity the subscription + accounts_with_name = [a for a in accounts if a['name'] == account_name] + if len(accounts_with_name) == 1: + # For unique name, only show the name + account_text = account_name + else: + # If more than 1 accounts have the same name, also show ID + account_text = '{} ({})'.format(account_name, account_id) + + return 'subscription', account_text + + +@suppress_all_exceptions() +def login_hinter(cli_ctx, result): # pylint: disable=unused-argument + account_type, account_text = _get_default_account_text(result.raw_result) + + command_placeholder = '{:44s}' + selected_sub = [ + (Style.PRIMARY, 'Your default {} is '.format(account_type)), + (Style.IMPORTANT, account_text), + ] + print_styled_text(selected_sub) + print_styled_text() + + # TRY + try_commands = [ + (Style.PRIMARY, 'TRY\n'), + (Style.PRIMARY, command_placeholder.format('az upgrade')), + (Style.SECONDARY, 'Upgrade to the latest CLI version in tool\n'), + (Style.PRIMARY, command_placeholder.format('az account set --subscription ')), + (Style.SECONDARY, 'Set your default subscription account\n'), + (Style.PRIMARY, command_placeholder.format('az config set output=table')), + (Style.SECONDARY, 'Set your default output to be in table format\n') + ] + print_styled_text(try_commands) + + +@suppress_all_exceptions() +def demo_hint_hinter(cli_ctx, result): # pylint: disable=unused-argument + result = result.raw_result + key_placeholder = '{:>25s}' # right alignment, 25 width + command_placeholder = '{:40s}' + projection = [ + (Style.PRIMARY, 'The hinter can parse the output to show a "projection" of the output, like\n\n'), + (Style.PRIMARY, key_placeholder.format('Subscription name: ')), + (Style.IMPORTANT, result['name']), + (Style.PRIMARY, '\n'), + (Style.PRIMARY, key_placeholder.format('Subscription ID: ')), + (Style.IMPORTANT, result['id']), + (Style.PRIMARY, '\n'), + (Style.PRIMARY, key_placeholder.format('User: ')), + (Style.IMPORTANT, result['user']['name']), + ] + print_styled_text(projection) + print_styled_text() + + # TRY + try_commands = [ + (Style.PRIMARY, 'TRY\n'), + (Style.PRIMARY, command_placeholder.format('az upgrade')), + (Style.SECONDARY, 'Upgrade to the latest CLI version in tool\n'), + (Style.PRIMARY, command_placeholder.format('az account set -s ')), + (Style.SECONDARY, 'Set your default subscription account\n'), + (Style.PRIMARY, command_placeholder.format('az config set output=table')), + (Style.SECONDARY, 'Set your default output to be in table format\n'), + (Style.PRIMARY, command_placeholder.format('az feedback')), + (Style.SECONDARY, 'File us your latest issue encountered\n'), + (Style.PRIMARY, command_placeholder.format('az next')), + (Style.SECONDARY, 'Get some ideas on next steps\n'), + ] + print_styled_text(try_commands) + + hyperlink = [ + (Style.PRIMARY, 'You may also show a hyperlink for more detail: '), + (Style.HYPERLINK, 'https://docs.microsoft.com/cli/azure/'), + ] + print_styled_text(hyperlink) diff --git a/src/azure-cli/azure/cli/command_modules/hint/tests/latest/__init__.py b/src/azure-cli/azure/cli/command_modules/hint/tests/latest/__init__.py new file mode 100644 index 00000000000..34913fb394d --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/hint/tests/latest/__init__.py @@ -0,0 +1,4 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- diff --git a/src/azure-cli/azure/cli/command_modules/hint/tests/latest/test_hint_custom.py b/src/azure-cli/azure/cli/command_modules/hint/tests/latest/test_hint_custom.py new file mode 100644 index 00000000000..3531fdd7539 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/hint/tests/latest/test_hint_custom.py @@ -0,0 +1,85 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import unittest +import mock + +from azure.cli.command_modules.hint.custom import _get_default_account_text + + +class HintTest(unittest.TestCase): + + def test_get_default_account_text(self): + test_accounts = [ + # Subscription with unique name + { + "cloudName": "AzureCloud", + "homeTenantId": "ca97aaa0-5a12-4ae3-8929-c8fb57dd93d6", + "id": "2b8e6bbc-631a-4bf6-b0c6-d4947b3c79dd", + "isDefault": False, + "managedByTenants": [], + "name": "Unique Name", + "state": "Enabled", + "tenantId": "ca97aaa0-5a12-4ae3-8929-c8fb57dd93d6", + "user": { + "name": "test@microsoft.com", + "type": "user" + } + }, + # Subscription with duplicated name + { + "cloudName": "AzureCloud", + "homeTenantId": "ca97aaa0-5a12-4ae3-8929-c8fb57dd93d6", + "id": "414af076-009b-4282-9a0a-acf75bcb037e", + "isDefault": False, + "managedByTenants": [], + "name": "Duplicated Name", + "state": "Enabled", + "tenantId": "ca97aaa0-5a12-4ae3-8929-c8fb57dd93d6", + "user": { + "name": "test@microsoft.com", + "type": "user" + } + }, + # Subscription with duplicated name + { + "cloudName": "AzureCloud", + "homeTenantId": "54826b22-38d6-4fb2-bad9-b7b93a3e9c5a", + "id": "0b1f6471-1bf0-4dda-aec3-cb9272f09590", + "isDefault": False, + "managedByTenants": [], + "name": "Duplicated Name", + "state": "Enabled", + "tenantId": "54826b22-38d6-4fb2-bad9-b7b93a3e9c5a", + "user": { + "name": "test@microsoft.com", + "type": "user" + } + }, + # Tenant + { + "cloudName": "AzureCloud", + "id": "246b1785-9030-40d8-a0f0-d94b15dc002c", + "isDefault": False, + "name": "N/A(tenant level account)", + "state": "Enabled", + "tenantId": "246b1785-9030-40d8-a0f0-d94b15dc002c", + "user": { + "name": "test@microsoft.com", + "type": "user" + } + } + ] + expected_result = [ + ("subscription", "Unique Name"), + ("subscription", "Duplicated Name (414af076-009b-4282-9a0a-acf75bcb037e)"), + ("subscription", "Duplicated Name (0b1f6471-1bf0-4dda-aec3-cb9272f09590)"), + ("tenant", "246b1785-9030-40d8-a0f0-d94b15dc002c") + ] + + for i in range(len(test_accounts)): + # Mark each account as default and check the result + with mock.patch.dict(test_accounts[i], {'isDefault': True}): + self.assertEqual(_get_default_account_text(test_accounts), expected_result[i]) diff --git a/src/azure-cli/azure/cli/command_modules/util/_help.py b/src/azure-cli/azure/cli/command_modules/util/_help.py index 08a9d506e74..d15ce9844c6 100644 --- a/src/azure-cli/azure/cli/command_modules/util/_help.py +++ b/src/azure-cli/azure/cli/command_modules/util/_help.py @@ -55,3 +55,8 @@ type: command short-summary: A demo showing supported text styles. """ + +helps['demo hint'] = """ +type: command +short-summary: A demo showing post-output hint. +""" diff --git a/src/azure-cli/azure/cli/command_modules/util/commands.py b/src/azure-cli/azure/cli/command_modules/util/commands.py index 05b6615ba47..d1b3583be73 100644 --- a/src/azure-cli/azure/cli/command_modules/util/commands.py +++ b/src/azure-cli/azure/cli/command_modules/util/commands.py @@ -17,3 +17,4 @@ def load_command_table(self, _): with self.command_group('demo', deprecate_info=g.deprecate(hide=True)) as g: g.custom_command('style', 'demo_style') + g.custom_command('hint', 'demo_hint') diff --git a/src/azure-cli/azure/cli/command_modules/util/custom.py b/src/azure-cli/azure/cli/command_modules/util/custom.py index 4919d7f9ded..7bbcc0a68f5 100644 --- a/src/azure-cli/azure/cli/command_modules/util/custom.py +++ b/src/azure-cli/azure/cli/command_modules/util/custom.py @@ -260,3 +260,25 @@ def demo_style(cmd, theme=None): # pylint: disable=unused-argument (Style.WARNING, "WARNING: The subscription has been disabled!") ] print_styled_text(styled_text) + + +def demo_hint(cmd): # pylint: disable=unused-argument + test_dict = { + "cloudName": "AzureCloud", + "homeTenantId": "54826b22-38d6-4fb2-bad9-b7b93a3e9c5a", + "id": "0b1f6471-1bf0-4dda-aec3-cb9272f09590", + "isDefault": True, + "managedByTenants": [ + { + "tenantId": "2f4a9838-26b7-47ee-be60-ccc1fdec5953" + } + ], + "name": "AzureSDKTest", + "state": "Enabled", + "tenantId": "54826b22-38d6-4fb2-bad9-b7b93a3e9c5a", + "user": { + "name": "rolelivetest@azuresdkteam.onmicrosoft.com", + "type": "user" + } + } + return test_dict