Skip to content

Commit

Permalink
Support Force & Confirm (#1783)
Browse files Browse the repository at this point in the history
- When registering a command, add `confirmation=True` to enable user confirmation. Also supports a string message or callable.
- Add a —force flag for commands that support this feature.
- Integrated with configuration system so it can be enabled/disabled by setting AZURE_CORE_DISABLE_CONFIRM_PROMPT

Added for the following commands:
az group delete
az vm delete
az network dns zone delete
  • Loading branch information
derekbekoe authored Jan 20, 2017
1 parent a71e140 commit f274679
Show file tree
Hide file tree
Showing 12 changed files with 113 additions and 65 deletions.
3 changes: 2 additions & 1 deletion doc/authoring_command_modules/authoring_commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,15 @@ 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__`.
- `name` - String uniquely naming your command and placing it within the command hierachy. It will be the string that you would type at the command line, omitting `az` (ex: access your command at `az mypackage mycommand` using a name of `mypackage mycommand`).
- `operation` - The handler that will be executed. Format is `<module_to_import>#<attribute_list>`
- 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.

Expand Down
63 changes: 63 additions & 0 deletions src/azure-cli-core/azure/cli/core/_prompting.py
Original file line number Diff line number Diff line change
@@ -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)
32 changes: 29 additions & 3 deletions src/azure-cli-core/azure/cli/core/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -28,6 +30,8 @@

# pylint: disable=too-many-arguments,too-few-public-methods

FORCE_PARAM_NAME = 'force'


class CliArgumentType(object):
REMOVE = '---REMOVE---'
Expand Down Expand Up @@ -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):
Expand All @@ -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))

Expand All @@ -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)
Expand Down Expand Up @@ -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'
Expand Down
4 changes: 2 additions & 2 deletions src/azure-cli-core/azure/cli/core/test_utils/vcr_test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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))
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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()
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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())

Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down

0 comments on commit f274679

Please sign in to comment.