Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Force & Confirm #1783

Merged
merged 1 commit into from
Jan 20, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not entirely sure I understand this. There are three cases:

  1. True => default prompt enabled
  2. callable => you have access to the command namespace (?) and return true or false? Do we use this?
  3. string => enables the prompt with a custom message.

Does using #2 mean you essentially are using your own handler and throwing away the default one?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your understanding of 1 & 3 is correct.
For 2, you can specify your own confirmation prompt handler. You get the command namespace so you can use it if required. e.g. Print a custom prompt based on the name of one of the parameters.

And no we do not currently use 2 or 3 but there are use-cases where they'll be useful.


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