diff --git a/awscli/clidocs.py b/awscli/clidocs.py index a9e44741fa02..833d7aba3800 100644 --- a/awscli/clidocs.py +++ b/awscli/clidocs.py @@ -232,7 +232,16 @@ def doc_subitems_start(self, help_command, **kwargs): def doc_subitem(self, command_name, help_command, **kwargs): doc = help_command.doc - doc.style.tocitem(command_name) + subcommand = help_command.command_table[command_name] + subcommand_table = getattr(subcommand, 'subcommand_table', {}) + # If the subcommand table has commands in it, + # direct the subitem to the command's index because + # it has more subcommands to be documented. + if (len(subcommand_table) > 0): + file_name = '%s/index' % command_name + doc.style.tocitem(command_name, file_name=file_name) + else: + doc.style.tocitem(command_name) class OperationDocumentEventHandler(CLIDocumentEventHandler): diff --git a/awscli/clidriver.py b/awscli/clidriver.py index cc559486995f..5567c68c8401 100644 --- a/awscli/clidriver.py +++ b/awscli/clidriver.py @@ -366,7 +366,8 @@ def _create_command_table(self): service_object=service_object) self.session.emit('building-command-table.%s' % self._name, command_table=command_table, - session=self.session) + session=self.session, + service_object=service_object) return command_table def create_help_command(self): diff --git a/awscli/customizations/waiters.py b/awscli/customizations/waiters.py new file mode 100644 index 000000000000..f292af2d403a --- /dev/null +++ b/awscli/customizations/waiters.py @@ -0,0 +1,216 @@ +# Copyright 2014 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific +# language governing permissions and limitations under the License. +from botocore import xform_name + +from awscli.clidriver import ServiceOperation +from awscli.customizations.commands import BasicCommand, BasicHelp, \ + BasicDocHandler + + +def register_add_waiters(cli): + cli.register('building-command-table', add_waiters) + + +def add_waiters(command_table, session, service_object=None, **kwargs): + # If a service object was passed in, try to add a wait command. + if service_object is not None: + # Get a client out of the service object. + client = translate_service_object_to_client(service_object) + # Find all of the waiters for that client. + waiters = client.waiter_names + # If there are waiters make a wait command. + if waiters: + command_table['wait'] = WaitCommand(client, service_object) + + +def translate_service_object_to_client(service_object): + # Create a client from a service object. + session = service_object.session + return session.create_client(service_object.service_name) + + +class WaitCommand(BasicCommand): + NAME = 'wait' + DESCRIPTION = 'Wait until a particular condition is satisfied.' + + def __init__(self, client, service_object): + self._client = client + self._service_object = service_object + self.waiter_cmd_builder = WaiterStateCommandBuilder( + client=self._client, + service_object=self._service_object + ) + super(WaitCommand, self).__init__(self._service_object.session) + + def _run_main(self, parsed_args, parsed_globals): + if parsed_args.subcommand is None: + raise ValueError("usage: aws [options] " + "[parameters]\naws: error: too few arguments") + + def _build_subcommand_table(self): + subcommand_table = super(WaitCommand, self)._build_subcommand_table() + self.waiter_cmd_builder.build_all_waiter_state_cmds(subcommand_table) + return subcommand_table + + def create_help_command(self): + return BasicHelp(self._session, self, + command_table=self.subcommand_table, + arg_table=self.arg_table, + event_handler_class=WaiterCommandDocHandler) + + +class WaiterStateCommandBuilder(object): + def __init__(self, client, service_object): + self._client = client + self._service_object = service_object + + def build_all_waiter_state_cmds(self, subcommand_table): + """This adds waiter state commands to the subcommand table passed in. + + This is the method that adds waiter state commands like + ``instance-running`` to ``ec2 wait``. + """ + waiters = self._client.waiter_names + for waiter_name in waiters: + waiter_cli_name = waiter_name.replace('_', '-') + subcommand_table[waiter_cli_name] = \ + self._build_waiter_state_cmd(waiter_name) + + def _build_waiter_state_cmd(self, waiter_name): + # Get the waiter + waiter = self._client.get_waiter(waiter_name) + + # Create the cli name for the waiter operation + waiter_cli_name = waiter_name.replace('_', '-') + + # Obtain the name of the service operation that is used to implement + # the specified waiter. + operation_name = waiter.config.operation + + # Create an operation object to make a command for the waiter. The + # operation object is used to generate the arguments for the waiter + # state command. + operation_object = self._service_object.get_operation(operation_name) + waiter_state_command = WaiterStateCommand( + name=waiter_cli_name, parent_name='wait', + operation_object=operation_object, + operation_caller=WaiterCaller(self._client, waiter), + service_object=self._service_object + ) + # Build the top level description for the waiter state command. + # Most waiters do not have a description so they need to be generated + # using the waiter configuration. + waiter_state_doc_builder = WaiterStateDocBuilder(waiter.config) + description = waiter_state_doc_builder.build_waiter_state_description() + waiter_state_command.DESCRIPTION = description + return waiter_state_command + + +class WaiterStateDocBuilder(object): + SUCCESS_DESCRIPTIONS = { + 'error': u'%s is thrown ', + 'path': u'%s ', + 'pathAll': u'%s for all elements ', + 'pathAny': u'%s for any element ', + 'status': u'%s response is received ' + } + + def __init__(self, waiter_config): + self._waiter_config = waiter_config + + def build_waiter_state_description(self): + description = self._waiter_config.description + # Use the description provided in the waiter config file. If no + # description is provided, use a heuristic to generate a description + # for the waiter. + if not description: + description = u'Wait until ' + # Look at all of the acceptors and find the success state + # acceptor. + for acceptor in self._waiter_config.acceptors: + # Build the description off of the success acceptor. + if acceptor.state == 'success': + description += self._build_success_description(acceptor) + break + # Include what operation is being used. + description += self._build_operation_description( + self._waiter_config.operation) + return description + + def _build_success_description(self, acceptor): + matcher = acceptor.matcher + # Pick the description template to use based on what the matcher is. + success_description = self.SUCCESS_DESCRIPTIONS[matcher] + resource_description = None + # If success is based off of the state of a resource include the + # description about what resource is looked at. + if matcher in ['path', 'pathAny', 'pathAll']: + resource_description = u'JMESPath query %s returns ' % \ + acceptor.argument + # Prepend the resource description to the template description + success_description = resource_description + success_description + # Complete the description by filling in the expected success state. + full_success_description = success_description % acceptor.expected + return full_success_description + + def _build_operation_description(self, operation): + operation_name = xform_name(operation).replace('_', '-') + return u'when polling with ``%s``.' % operation_name + + +class WaiterCaller(object): + def __init__(self, client, waiter): + self._client = client + self._waiter = waiter + + def invoke(self, operation_object, parameters, parsed_globals): + # Create the endpoint based on the parsed globals + endpoint = operation_object.service.get_endpoint( + region_name=parsed_globals.region, + endpoint_url=parsed_globals.endpoint_url, + verify=parsed_globals.verify_ssl) + # Change the client's endpoint using the newly configured endpoint + self._client._endpoint = endpoint + # Call the waiter's wait method. + self._waiter.wait(**parameters) + return 0 + + +class WaiterStateCommand(ServiceOperation): + DESCRIPTION = '' + + def create_help_command(self): + help_command = super(WaiterStateCommand, self).create_help_command() + # Change the operation object's description by changing it to the + # description for a waiter state command. + self._operation_object.documentation = self.DESCRIPTION + # Change the output shape because waiters provide no output. + self._operation_object.model.output_shape = None + return help_command + + +class WaiterCommandDocHandler(BasicDocHandler): + def doc_synopsis_start(self, help_command, **kwargs): + pass + + def doc_synopsis_option(self, arg_name, help_command, **kwargs): + pass + + def doc_synopsis_end(self, help_command, **kwargs): + pass + + def doc_options_start(self, help_command, **kwargs): + pass + + def doc_option(self, arg_name, help_command, **kwargs): + pass diff --git a/awscli/handlers.py b/awscli/handlers.py index f7b36287b71a..7087d3d8201a 100644 --- a/awscli/handlers.py +++ b/awscli/handlers.py @@ -52,6 +52,7 @@ from awscli.customizations.generatecliskeleton import \ register_generate_cli_skeleton from awscli.customizations.assumerole import register_assume_role_provider +from awscli.customizations.waiters import register_add_waiters def awscli_initialize(event_handlers): @@ -107,3 +108,4 @@ def awscli_initialize(event_handlers): register_s3_endpoint(event_handlers) register_generate_cli_skeleton(event_handlers) register_assume_role_provider(event_handlers) + register_add_waiters(event_handlers) diff --git a/doc/source/htmlgen b/doc/source/htmlgen index 36e5a2461e5f..57593c764ad4 100755 --- a/doc/source/htmlgen +++ b/doc/source/htmlgen @@ -33,8 +33,10 @@ def do_operation(driver, service_path, operation_name, operation_command): help_command(None, None) -def do_service(driver, ref_path, service_name, service_command): - print('...%s' % service_name) +def do_service(driver, ref_path, service_name, service_command, + is_top_level_service=True): + if is_top_level_service: + print('...%s' % service_name) service_path = os.path.join(ref_path, service_name) if not os.path.isdir(service_path): os.mkdir(service_path) @@ -50,7 +52,16 @@ def do_service(driver, ref_path, service_name, service_command): if operation_name == 'help': continue operation_command = help_command.command_table[operation_name] - do_operation(driver, service_path, operation_name, operation_command) + subcommand_table = getattr(operation_command, 'subcommand_table', {}) + # If the operation command has a subcommand table with commands + # in it, treat it as a service command as opposed to an operation + # command. + if (len(subcommand_table) > 0): + do_service(driver, service_path, operation_name, + operation_command, False) + else: + do_operation(driver, service_path, operation_name, + operation_command) def do_provider(driver): diff --git a/tests/integration/customizations/test_waiters.py b/tests/integration/customizations/test_waiters.py new file mode 100644 index 000000000000..b09ca746fe4c --- /dev/null +++ b/tests/integration/customizations/test_waiters.py @@ -0,0 +1,44 @@ +# Copyright 2014 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific +# language governing permissions and limitations under the License. +import botocore.session +import random + +from awscli.testutils import unittest, aws + + +class TestDynamoDBWait(unittest.TestCase): + def setUp(self): + self.session = botocore.session.get_session() + self.client = self.session.create_client('dynamodb', 'us-west-2') + + def test_wait_table_exists(self): + # Create a table. + table_name = 'awscliddb-%s' % random.randint(1, 10000) + self.client.create_table( + TableName=table_name, + ProvisionedThroughput={"ReadCapacityUnits": 5, + "WriteCapacityUnits": 5}, + KeySchema=[{"AttributeName": "foo", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "foo", + "AttributeType": "S"}]) + self.addCleanup(self.client.delete_table, TableName=table_name) + + # Wait for the table to be active. + p = aws( + 'dynamodb wait table-exists --table-name %s --region us-west-2' % + table_name) + self.assertEqual(p.rc, 0) + + # Make sure the table is active. + parsed = self.client.describe_table(TableName=table_name) + self.assertEqual(parsed['Table']['TableStatus'], 'ACTIVE') diff --git a/tests/unit/customizations/test_waiters.py b/tests/unit/customizations/test_waiters.py new file mode 100644 index 000000000000..3de876c02f14 --- /dev/null +++ b/tests/unit/customizations/test_waiters.py @@ -0,0 +1,319 @@ +# Copyright 2014 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific +# language governing permissions and limitations under the License. +import mock + +from awscli.testutils import unittest, BaseAWSHelpOutputTest, \ + BaseAWSCommandParamsTest +from awscli.customizations.waiters import add_waiters, WaitCommand, \ + translate_service_object_to_client, WaiterStateCommand, WaiterCaller, \ + WaiterStateDocBuilder, WaiterStateCommandBuilder + + +class TestAddWaiters(unittest.TestCase): + def setUp(self): + self.service_object = mock.Mock() + self.session = mock.Mock() + self.client = mock.Mock() + + # Set up the mock service object. + self.service_object.session = self.session + + # Set up the mock session. + self.session.create_client.return_value = self.client + + # Set up the mock client. + self.client.waiter_names = ['waiter'] + + def test_add_waiters(self): + command_table = {} + add_waiters(command_table, self.session, self.service_object) + # Make sure a wait command was added. + self.assertIn('wait', command_table) + self.assertIsInstance(command_table['wait'], WaitCommand) + + def test_add_waiters_no_waiter_names(self): + self.client.waiter_names = [] + command_table = {} + add_waiters(command_table, self.session, self.service_object) + # Make sure that no wait command was added since the service object + # has no waiters. + self.assertEqual(command_table, {}) + + def test_add_waiters_no_service_object(self): + command_table = {} + add_waiters(command_table, self.session, None) + # Make sure that no wait command was added since no service object + # was passed in. + self.assertEqual(command_table, {}) + + +class TestTranslateServiceObjectToClient(unittest.TestCase): + def test_translate_service_object_to_client(self): + service_object = mock.Mock() + session = mock.Mock() + service_object.session = session + service_object.service_name = 'service' + translate_service_object_to_client(service_object) + session.create_client.assert_called_with('service') + + +class TestWaitCommand(unittest.TestCase): + def setUp(self): + self.client = mock.Mock() + self.service_object = mock.Mock() + self.cmd = WaitCommand(self.client, self.service_object) + + def test_run_main_error(self): + self.parsed_args = mock.Mock() + self.parsed_args.subcommand = None + with self.assertRaises(ValueError): + self.cmd._run_main(self.parsed_args, None) + + +class TestWaitHelpOutput(BaseAWSHelpOutputTest): + def test_wait_command_is_in_list(self): + self.driver.main(['ec2', 'help']) + self.assert_contains('* wait') + + def test_wait_help_command(self): + self.driver.main(['ec2', 'wait', 'help']) + self.assert_contains('Wait until a particular condition is satisfied.') + self.assert_contains('* instance-running') + self.assert_contains('* vpc-available') + + def test_wait_state_help_command(self): + self.driver.main(['ec2', 'wait', 'instance-running', 'help']) + self.assert_contains('``describe-instances``') + self.assert_contains('[--filters ]') + self.assert_contains('``--filters`` (list)') + + +class TestWait(BaseAWSCommandParamsTest): + """ This is merely a smoke test. + + Its purpose is to test that the wait command can be run proberly for + various services. It is by no means exhaustive. + """ + def test_ec2_instance_running(self): + cmdline = 'ec2 wait instance-running' + cmdline += ' --instance-ids i-12345678 i-87654321' + cmdline += """ --filters {"Name":"group-name","Values":["foobar"]}""" + result = {'Filter.1.Value.1': 'foobar', 'Filter.1.Name': 'group-name', + 'InstanceId.1': 'i-12345678', 'InstanceId.2': 'i-87654321'} + self.parsed_response = { + 'Reservations': [{ + 'Instances': [{ + 'State': { + 'Name': 'running' + } + }] + }] + } + self.assert_params_for_cmd(cmdline, result) + + def test_dynamodb_table_exists(self): + cmdline = 'dynamodb wait table-exists' + cmdline += ' --table-name mytable' + result = '{"TableName": "mytable"}' + self.parsed_response = {'Table': {'TableStatus': 'ACTIVE'}} + self.assert_params_for_cmd(cmdline, result) + + def test_elastictranscoder_jobs_complete(self): + cmdline = 'rds wait db-instance-available' + cmdline += ' --db-instance-identifier abc' + result = {'DBInstanceIdentifier': 'abc'} + self.parsed_response = { + 'DBInstances': [{ + 'DBInstanceStatus': 'available' + }] + } + self.assert_params_for_cmd(cmdline, result) + + +class TestWaiterStateCommandBuilder(unittest.TestCase): + def setUp(self): + self.client = mock.Mock() + self.service_object = mock.Mock() + + # Create some waiters. + self.client.waiter_names = ['instance_running', 'bucket_exists'] + self.instance_running_waiter = mock.Mock() + self.bucket_exists_waiter = mock.Mock() + + # Make a mock waiter config. + self.waiter_config = mock.Mock() + self.waiter_config.operation = 'MyOperation' + self.waiter_config.description = 'my waiter description' + self.instance_running_waiter.config = self.waiter_config + self.bucket_exists_waiter.config = self.waiter_config + + self.client.get_waiter.side_effect = [ + self.instance_running_waiter, self.bucket_exists_waiter] + + self.waiter_builder = WaiterStateCommandBuilder( + self.client, + self.service_object + ) + + def test_build_waiter_state_cmds(self): + subcommand_table = {} + self.waiter_builder.build_all_waiter_state_cmds(subcommand_table) + # Check the commands are in the command table + self.assertEqual(len(subcommand_table), 2) + self.assertIn('instance-running', subcommand_table) + self.assertIn('bucket-exists', subcommand_table) + + # Make sure that the correct operation object was used. + self.service_object.get_operation.assert_called_with('MyOperation') + + # Introspect the commands in the command table + instance_running_cmd = subcommand_table['instance-running'] + bucket_exists_cmd = subcommand_table['bucket-exists'] + + # Check that the instance type is correct. + self.assertIsInstance(instance_running_cmd, WaiterStateCommand) + self.assertIsInstance(bucket_exists_cmd, WaiterStateCommand) + + # Check the descriptions are set correctly. + self.assertEqual( + instance_running_cmd.DESCRIPTION, + self.waiter_config.description + ) + self.assertEqual( + bucket_exists_cmd.DESCRIPTION, + self.waiter_config.description + ) + + +class TestWaiterStateDocBuilder(unittest.TestCase): + def setUp(self): + self.waiter_config = mock.Mock() + self.waiter_config.description = '' + self.waiter_config.operation = 'MyOperation' + + # Set up the acceptors. + self.success_acceptor = mock.Mock() + self.success_acceptor.state = 'success' + self.fail_acceptor = mock.Mock() + self.fail_acceptor.state = 'failure' + self.error_acceptor = mock.Mock() + self.error_acceptor.state = 'error' + self.waiter_config.acceptors = [ + self.fail_acceptor, + self.success_acceptor, + self.error_acceptor + ] + + self.doc_builder = WaiterStateDocBuilder(self.waiter_config) + + def test_config_provided_description(self): + # Description is provided by the config file + self.waiter_config.description = 'my description' + description = self.doc_builder.build_waiter_state_description() + self.assertEqual(description, 'my description') + + def test_error_acceptor(self): + self.success_acceptor.matcher = 'error' + self.success_acceptor.expected = 'MyException' + description = self.doc_builder.build_waiter_state_description() + self.assertEqual( + description, + 'Wait until MyException is thrown when polling with ' + '``my-operation``.' + ) + + def test_status_acceptor(self): + self.success_acceptor.matcher = 'status' + self.success_acceptor.expected = 200 + description = self.doc_builder.build_waiter_state_description() + self.assertEqual( + description, + 'Wait until 200 response is received when polling with ' + '``my-operation``.' + ) + + def test_path_acceptor(self): + self.success_acceptor.matcher = 'path' + self.success_acceptor.argument = 'MyResource.name' + self.success_acceptor.expected = 'running' + description = self.doc_builder.build_waiter_state_description() + self.assertEqual( + description, + 'Wait until JMESPath query MyResource.name returns running when ' + 'polling with ``my-operation``.' + ) + + def test_path_all_acceptor(self): + self.success_acceptor.matcher = 'pathAll' + self.success_acceptor.argument = 'MyResource[].name' + self.success_acceptor.expected = 'running' + description = self.doc_builder.build_waiter_state_description() + self.assertEqual( + description, + 'Wait until JMESPath query MyResource[].name returns running for ' + 'all elements when polling with ``my-operation``.' + ) + + def test_path_any_acceptor(self): + self.success_acceptor.matcher = 'pathAny' + self.success_acceptor.argument = 'MyResource[].name' + self.success_acceptor.expected = 'running' + description = self.doc_builder.build_waiter_state_description() + self.assertEqual( + description, + 'Wait until JMESPath query MyResource[].name returns running for ' + 'any element when polling with ``my-operation``.' + ) + + +class TestWaiterCaller(unittest.TestCase): + def test_invoke(self): + client = mock.Mock() + waiter = mock.Mock() + operation_object = mock.Mock() + + parameters = {'Foo': 'bar', 'Baz': 'biz'} + parsed_globals = mock.Mock() + parsed_globals.region = 'us-east-1' + parsed_globals.endpoint_url = 'myurl' + parsed_globals.verify_ssl = True + + waiter_caller = WaiterCaller(client, waiter) + waiter_caller.invoke(operation_object, parameters, parsed_globals) + # Make sure the endpoint was created properly + operation_object.service.get_endpoint.assert_called_with( + region_name=parsed_globals.region, + endpoint_url=parsed_globals.endpoint_url, + verify=parsed_globals.verify_ssl + ) + # Ensure the wait command was called properly. + waiter.wait.assert_called_with( + Foo='bar', Baz='biz') + + +class TestWaiterStateCommand(unittest.TestCase): + def test_create_help_command(self): + operation_object = mock.Mock() + operation_object.model.input_shape = None + cmd = WaiterStateCommand( + name='wait-state', parent_name='wait', + operation_object=operation_object, + operation_caller=mock.Mock(), + service_object=mock.Mock() + ) + cmd.DESCRIPTION = 'mydescription' + cmd.create_help_command() + # Make sure that the description is used and output shape is set + # to None for creating the help command. + self.assertEqual(operation_object.documentation, 'mydescription') + self.assertIsNone(operation_object.model.output_shape) diff --git a/tests/unit/test_completer.py b/tests/unit/test_completer.py index 6ef516494ef1..3c22b837e737 100644 --- a/tests/unit/test_completer.py +++ b/tests/unit/test_completer.py @@ -98,7 +98,7 @@ 'modify-cluster-attributes', 'modify-instance-groups', 'put', 'remove-tags', 'restore-from-hbase-backup', 'schedule-hbase-backup', 'socks', 'ssh', - 'terminate-clusters'])) + 'terminate-clusters', 'wait'])) ]