diff --git a/doc/authoring_command_modules/authoring_commands.md b/doc/authoring_command_modules/authoring_commands.md index e6a6ebe4096..2333f398554 100644 --- a/doc/authoring_command_modules/authoring_commands.md +++ b/doc/authoring_command_modules/authoring_commands.md @@ -35,7 +35,7 @@ from azure.cli.commands import cli_command The signature of this method is ```Python -def cli_command(module_name, name, operation, client_factory=None, transform=None, table_transformer=None): +def cli_command(module_name, name, operation, client_factory=None, transform=None, table_transformer=None, confirmation=None): ``` You will generally only specify `name`, `operation` and possibly `table_transformer`. - `module_name` - The name of the module that is registering the command (e.g. `azure.cli.command_modules.vm.commands`). Typically this will be `__name__`. @@ -43,6 +43,7 @@ You will generally only specify `name`, `operation` and possibly `table_transfor - `operation` - The handler that will be executed. Format is `#` - For example if `operation='azure.mgmt.compute.operations.virtual_machines_operations#VirtualMachinesOperations.get'`, the CLI will import `azure.mgmt.compute.operations.virtual_machines_operations`, get the `VirtualMachinesOperations` attribute and then the `get` attribute of `VirtualMachinesOperations`. - `table_transformer` (optional) - Supply a callable that takes, transforms and returns a result for table output. + - `confirmation` (optional) - Supply True to enable default confirmation. Alternatively, supply a callable that takes the command arguments as a dict and returning a boolean. Alternatively, supply a string for the prompt. At this point, you should be able to access your command using `az [name]` and access the built-in help with `az [name] -h/--help`. Your command will automatically be 'wired up' with the global parameters. diff --git a/src/azure-cli-core/azure/cli/core/_prompting.py b/src/azure-cli-core/azure/cli/core/_prompting.py new file mode 100644 index 00000000000..a590c107ddf --- /dev/null +++ b/src/azure-cli-core/azure/cli/core/_prompting.py @@ -0,0 +1,63 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import sys +from six.moves import input # pylint: disable=redefined-builtin + +import azure.cli.core._logging as _logging + +logger = _logging.get_az_logger(__name__) + + +class NoTTYException(Exception): + pass + + +def _verify_is_a_tty(): + if not sys.stdin.isatty(): + logger.debug('No tty available.') + raise NoTTYException() + + +def prompt_y_n(msg, default=None): + _verify_is_a_tty() + if default not in [None, 'y', 'n']: + raise ValueError("Valid values for default are 'y', 'n' or None") + y = 'Y' if default == 'y' else 'y' + n = 'N' if default == 'n' else 'n' + while True: + ans = input('{} ({}/{}): '.format(msg, y, n)) + if ans.lower() == n.lower(): + return False + if ans.lower() == y.lower(): + return True + if default and not ans: + return default == y.lower() + + +def prompt_choice_list(msg, a_list, default=1): + '''Prompt user to select from a list of possible choices. + :param str msg:A message displayed to the user before the choice list + :param str a_list:The list of choices (list of strings or list of dicts with 'name' & 'desc') + :param int default:The default option that should be chosen if user doesn't enter a choice + :returns: The list index of the item chosen. + ''' + _verify_is_a_tty() + options = '\n'.join([' [{}] {}{}' + .format(i + 1, + x['name'] if isinstance(x, dict) and 'name' in x else x, + ' - ' + x['desc'] if isinstance(x, dict) and 'desc' in x else '') + for i, x in enumerate(a_list)]) + allowed_vals = list(range(1, len(a_list) + 1)) + while True: + try: + ans = int(input('{}\n{}\nPlease enter a choice [{}]: '.format(msg, options, default)) or + default) + if ans in allowed_vals: + # array index is 0-based, user input is 1-based + return ans - 1 + raise ValueError + except ValueError: + logger.warning('Valid values are %s', allowed_vals) 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 9794a3691cb..fcb5eec7f3a 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -19,6 +19,8 @@ import azure.cli.core.telemetry as telemetry from azure.cli.core._util import CLIError from azure.cli.core.application import APPLICATION +from azure.cli.core._prompting import prompt_y_n, NoTTYException +from azure.cli.core._config import az_config from ._introspection import (extract_args_from_signature, extract_full_summary_from_signature) @@ -28,6 +30,8 @@ # pylint: disable=too-many-arguments,too-few-public-methods +FORCE_PARAM_NAME = 'force' + class CliArgumentType(object): REMOVE = '---REMOVE---' @@ -263,10 +267,10 @@ def register_extra_cli_argument(command, dest, **kwargs): def cli_command(module_name, name, operation, client_factory=None, transform=None, table_transformer=None, - no_wait_param=None): + no_wait_param=None, confirmation=None): """ Registers a default Azure CLI command. These commands require no special parameters. """ command_table[name] = create_command(module_name, name, operation, transform, table_transformer, - client_factory, no_wait_param) + client_factory, no_wait_param, confirmation=confirmation) def get_op_handler(operation): @@ -283,7 +287,7 @@ def get_op_handler(operation): def create_command(module_name, name, operation, transform_result, table_transformer, client_factory, - no_wait_param=None): + no_wait_param=None, confirmation=None): if not isinstance(operation, string_types): raise ValueError("Operation must be a string. Got '{}'".format(operation)) @@ -293,6 +297,12 @@ def _execute_command(kwargs): from msrestazure.azure_operation import AzureOperationPoller from azure.common import AzureException + if confirmation \ + and not kwargs.get(FORCE_PARAM_NAME) \ + and not az_config.getboolean('core', 'disable_confirm_prompt', fallback=False) \ + and not _user_confirmed(confirmation, kwargs): + raise CLIError('Operation cancelled.') + client = client_factory(kwargs) if client_factory else None try: op = get_op_handler(operation) @@ -340,9 +350,25 @@ def description_loader(): cmd = CliCommand(name, _execute_command, table_transformer=table_transformer, arguments_loader=arguments_loader, description_loader=description_loader) + if confirmation: + cmd.add_argument(FORCE_PARAM_NAME, + action='store_true', + help='Do not prompt for confirmation') return cmd +def _user_confirmed(confirmation, command_args): + if callable(confirmation): + return confirmation(command_args) + try: + if isinstance(confirmation, string_types): + return prompt_y_n(confirmation) + return prompt_y_n('Are you sure you want to perform this operation?') + except NoTTYException: + logger.warning('Unable to prompt for confirmation as no tty available. Use --force.') + return False + + def _polish_rp_not_registerd_error(cli_error): msg = str(cli_error) pertinent_text_namespace = 'The subscription must be registered to use namespace' diff --git a/src/azure-cli-core/azure/cli/core/test_utils/vcr_test_base.py b/src/azure-cli-core/azure/cli/core/test_utils/vcr_test_base.py index b8c2eda475f..abb559e3d9b 100644 --- a/src/azure-cli-core/azure/cli/core/test_utils/vcr_test_base.py +++ b/src/azure-cli-core/azure/cli/core/test_utils/vcr_test_base.py @@ -463,7 +463,7 @@ def set_up(self): self.location, self.resource_group)) def tear_down(self): - self.cmd('group delete --name {} --no-wait'.format(self.resource_group)) + self.cmd('group delete --name {} --no-wait --force'.format(self.resource_group)) class StorageAccountVCRTestBase(VCRTestBase): @@ -491,4 +491,4 @@ def set_up(self): def tear_down(self): self.cmd('storage account delete -g {} -n {}'.format(self.resource_group, self.account)) - self.cmd('group delete --name {} --no-wait'.format(self.resource_group)) + self.cmd('group delete --name {} --no-wait --force'.format(self.resource_group)) diff --git a/src/command_modules/azure-cli-appservice/azure/cli/command_modules/appservice/tests/test_webapp_commands.py b/src/command_modules/azure-cli-appservice/azure/cli/command_modules/appservice/tests/test_webapp_commands.py index 888086c9fd9..f290cfaccb9 100644 --- a/src/command_modules/azure-cli-appservice/azure/cli/command_modules/appservice/tests/test_webapp_commands.py +++ b/src/command_modules/azure-cli-appservice/azure/cli/command_modules/appservice/tests/test_webapp_commands.py @@ -218,7 +218,7 @@ def set_up(self): def tear_down(self): super(AppServiceBadErrorPolishTest, self).tear_down() - self.cmd('group delete -n {}'.format(self.resource_group2)) + self.cmd('group delete -n {} --force'.format(self.resource_group2)) def body(self): # we will try to produce an error by try creating 2 webapp with same name in different groups diff --git a/src/command_modules/azure-cli-configure/azure/cli/command_modules/configure/_utils.py b/src/command_modules/azure-cli-configure/azure/cli/command_modules/configure/_utils.py index 968a13d0e9c..0a8eced6835 100644 --- a/src/command_modules/azure-cli-configure/azure/cli/command_modules/configure/_utils.py +++ b/src/command_modules/azure-cli-configure/azure/cli/command_modules/configure/_utils.py @@ -3,49 +3,7 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -from six.moves import input, configparser #pylint: disable=redefined-builtin - -import azure.cli.core._logging as _logging - -logger = _logging.get_az_logger(__name__) - -def prompt_y_n(msg, default=None): - if default not in [None, 'y', 'n']: - raise ValueError("Valid values for default are 'y', 'n' or None") - y = 'Y' if default == 'y' else 'y' - n = 'N' if default == 'n' else 'n' - while True: - ans = input('{} ({}/{}): '.format(msg, y, n)) - if ans.lower() == n.lower(): - return False - if ans.lower() == y.lower(): - return True - if default and not ans: - return default == y.lower() - -def prompt_choice_list(msg, a_list, default=1): - '''Prompt user to select from a list of possible choices. - :param str msg:A message displayed to the user before the choice list - :param str a_list:The list of choices (list of strings or list of dicts with 'name' & 'desc') - :param int default:The default option that should be chosen if user doesn't enter a choice - :returns: The list index of the item chosen. - ''' - options = '\n'.join([' [{}] {}{}'\ - .format(i+1, - x['name'] if isinstance(x, dict) and 'name' in x else x,\ - ' - '+x['desc'] if isinstance(x, dict) and 'desc' in x else '') \ - for i, x in enumerate(a_list)]) - allowed_vals = list(range(1, len(a_list)+1)) - while True: - try: - ans = int(input('{}\n{}\nPlease enter a choice [{}]: '.format(msg, options, default)) \ - or default) - if ans in allowed_vals: - # array index is 0-based, user input is 1-based - return ans-1 - raise ValueError - except ValueError: - logger.warning('Valid values are %s', allowed_vals) +from six.moves import configparser #pylint: disable=redefined-builtin def get_default_from_config(config, section, option, choice_list, fallback=1): try: diff --git a/src/command_modules/azure-cli-configure/azure/cli/command_modules/configure/custom.py b/src/command_modules/azure-cli-configure/azure/cli/command_modules/configure/custom.py index 58c5dbcefe3..4a7c8806e58 100644 --- a/src/command_modules/azure-cli-configure/azure/cli/command_modules/configure/custom.py +++ b/src/command_modules/azure-cli-configure/azure/cli/command_modules/configure/custom.py @@ -14,7 +14,7 @@ CONTEXT_CONFIG_DIR, ACTIVE_CONTEXT_CONFIG_PATH, ENV_VAR_PREFIX) from azure.cli.core._util import CLIError - +from azure.cli.core._prompting import (prompt_y_n, prompt_choice_list, NoTTYException) from azure.cli.command_modules.configure._consts import (OUTPUT_LIST, CLOUD_LIST, LOGIN_METHOD_LIST, MSG_INTRO, MSG_CLOSING, @@ -29,9 +29,7 @@ MSG_PROMPT_WHICH_CLOUD, MSG_PROMPT_LOGIN, MSG_PROMPT_TELEMETRY) -from azure.cli.command_modules.configure._utils import (prompt_y_n, - prompt_choice_list, - get_default_from_config) +from azure.cli.command_modules.configure._utils import get_default_from_config import azure.cli.command_modules.configure._help # pylint: disable=unused-import import azure.cli.core.telemetry as telemetry @@ -202,5 +200,7 @@ def handle_configure(): # _handle_context_configuration() print(MSG_CLOSING) # TODO: log_telemetry('configure', **answers) + except NoTTYException: + raise CLIError('This command is interactive and no tty available.') except (EOFError, KeyboardInterrupt): print() diff --git a/src/command_modules/azure-cli-network/azure/cli/command_modules/network/commands.py b/src/command_modules/azure-cli-network/azure/cli/command_modules/network/commands.py index 3f7e0e97a05..3dbc356fe0e 100644 --- a/src/command_modules/azure-cli-network/azure/cli/command_modules/network/commands.py +++ b/src/command_modules/azure-cli-network/azure/cli/command_modules/network/commands.py @@ -402,7 +402,7 @@ def _make_singular(value): # DNS ZonesOperations cli_command(__name__, 'network dns zone show', 'azure.mgmt.dns.operations.zones_operations#ZonesOperations.get', cf_dns_mgmt_zones) -cli_command(__name__, 'network dns zone delete', 'azure.mgmt.dns.operations.zones_operations#ZonesOperations.delete', cf_dns_mgmt_zones) +cli_command(__name__, 'network dns zone delete', 'azure.mgmt.dns.operations.zones_operations#ZonesOperations.delete', cf_dns_mgmt_zones, confirmation=True) cli_command(__name__, 'network dns zone list', custom_path.format('list_dns_zones')) cli_generic_update_command(__name__, 'network dns zone update', 'azure.mgmt.dns.operations.zones_operations#ZonesOperations.get', diff --git a/src/command_modules/azure-cli-resource/azure/cli/command_modules/resource/commands.py b/src/command_modules/azure-cli-resource/azure/cli/command_modules/resource/commands.py index 600b4034780..107d68b090d 100644 --- a/src/command_modules/azure-cli-resource/azure/cli/command_modules/resource/commands.py +++ b/src/command_modules/azure-cli-resource/azure/cli/command_modules/resource/commands.py @@ -25,7 +25,7 @@ def transform_resource_group_list(result): cli_command(__name__, 'group delete', 'azure.mgmt.resource.resources.operations.resource_groups_operations#ResourceGroupsOperations.delete', cf_resource_groups, - no_wait_param='raw') + no_wait_param='raw', confirmation=True) cli_generic_wait_command(__name__, 'group wait', 'azure.mgmt.resource.resources.operations.resource_groups_operations#ResourceGroupsOperations.get', cf_resource_groups) cli_command(__name__, 'group show', 'azure.mgmt.resource.resources.operations.resource_groups_operations#ResourceGroupsOperations.get', cf_resource_groups) cli_command(__name__, 'group exists', 'azure.mgmt.resource.resources.operations.resource_groups_operations#ResourceGroupsOperations.check_existence', cf_resource_groups) diff --git a/src/command_modules/azure-cli-resource/azure/cli/command_modules/resource/tests/test_resource.py b/src/command_modules/azure-cli-resource/azure/cli/command_modules/resource/tests/test_resource.py index 2ebd095aef6..a9a46eadc2c 100644 --- a/src/command_modules/azure-cli-resource/azure/cli/command_modules/resource/tests/test_resource.py +++ b/src/command_modules/azure-cli-resource/azure/cli/command_modules/resource/tests/test_resource.py @@ -23,7 +23,7 @@ def __init__(self, test_method): def set_up(self): if self.cmd('group exists -n {}'.format(self.resource_group)): - self.cmd('group delete -n {}'.format(self.resource_group)) + self.cmd('group delete -n {} --force'.format(self.resource_group)) def body(self): s = self @@ -41,12 +41,12 @@ def body(self): JMESPathCheck('[0].name', rg), JMESPathCheck('[0].tags', {'a':'b', 'c':''}) ]) - s.cmd('group delete -n {}'.format(rg)) + s.cmd('group delete -n {} --force'.format(rg)) s.cmd('group exists -n {}'.format(rg), checks=NoneCheck()) def tear_down(self): if self.cmd('group exists -n {}'.format(self.resource_group)): - self.cmd('group delete -n {}'.format(self.resource_group)) + self.cmd('group delete -n {} --force'.format(self.resource_group)) class ResourceGroupNoWaitScenarioTest(VCRTestBase): # Not RG test base because it tests the actual deletion of a resource group @@ -59,7 +59,7 @@ def __init__(self, test_method): def set_up(self): if self.cmd('group exists -n {}'.format(self.resource_group)): - self.cmd('group delete -n {}'.format(self.resource_group)) + self.cmd('group delete -n {} --force'.format(self.resource_group)) def body(self): s = self @@ -69,7 +69,7 @@ def body(self): ]) s.cmd('group exists -n {}'.format(rg), checks=BooleanCheck(True)) s.cmd('group wait --exists -n {}'.format(rg), checks=NoneCheck()) - s.cmd('group delete -n {} --no-wait'.format(rg), checks=NoneCheck()) + s.cmd('group delete -n {} --no-wait --force'.format(rg), checks=NoneCheck()) s.cmd('group wait --deleted -n {}'.format(rg), checks=NoneCheck()) s.cmd('group exists -n {}'.format(rg), checks=NoneCheck()) @@ -331,8 +331,8 @@ def set_up(self): self.cmd('group create --location westus --name {}'.format(self.destination_group)) def tear_down(self): - self.cmd('group delete --name {}'.format(self.source_group)) - self.cmd('group delete --name {}'.format(self.destination_group)) + self.cmd('group delete --name {} --force'.format(self.source_group)) + self.cmd('group delete --name {} --force'.format(self.destination_group)) def body(self): if self.playback: diff --git a/src/command_modules/azure-cli-vm/azure/cli/command_modules/vm/commands.py b/src/command_modules/azure-cli-vm/azure/cli/command_modules/vm/commands.py index ca5b2422edc..16df784439e 100644 --- a/src/command_modules/azure-cli-vm/azure/cli/command_modules/vm/commands.py +++ b/src/command_modules/azure-cli-vm/azure/cli/command_modules/vm/commands.py @@ -25,7 +25,7 @@ op_var = 'virtual_machines_operations' op_class = 'VirtualMachinesOperations' -cli_command(__name__, 'vm delete', mgmt_path.format(op_var, op_class, 'delete'), cf_vm) +cli_command(__name__, 'vm delete', mgmt_path.format(op_var, op_class, 'delete'), cf_vm, confirmation=True) cli_command(__name__, 'vm deallocate', mgmt_path.format(op_var, op_class, 'deallocate'), cf_vm) cli_command(__name__, 'vm generalize', mgmt_path.format(op_var, op_class, 'generalize'), cf_vm) cli_command(__name__, 'vm show', mgmt_path.format(op_var, op_class, 'get'), cf_vm) diff --git a/src/command_modules/azure-cli-vm/azure/cli/command_modules/vm/tests/test_vm_commands.py b/src/command_modules/azure-cli-vm/azure/cli/command_modules/vm/tests/test_vm_commands.py index 74cb8ea865e..e7be1345c00 100644 --- a/src/command_modules/azure-cli-vm/azure/cli/command_modules/vm/tests/test_vm_commands.py +++ b/src/command_modules/azure-cli-vm/azure/cli/command_modules/vm/tests/test_vm_commands.py @@ -387,7 +387,7 @@ def body(self): self.cmd('vm deallocate --resource-group {} --name {}'.format( self.resource_group, self.vm_name)) self._check_vm_power_state('PowerState/deallocated') - self.cmd('vm delete --resource-group {} --name {}'.format( + self.cmd('vm delete --resource-group {} --name {} --force'.format( self.resource_group, self.vm_name)) # Expecting no results self.cmd('vm list --resource-group {}'.format(self.resource_group), checks=NoneCheck())