diff --git a/.travis.yml b/.travis.yml index aa013eb5e0c9..f706fb66a281 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,7 +4,7 @@ python: - "2.7" - "3.3" install: - - if [[ $TRAVIS_PYTHON_VERSION == '2.6' ]]; then pip install unittest2; fi + - if [[ $TRAVIS_PYTHON_VERSION == '2.6' ]]; then pip install unittest2==0.5.1; fi - pip install -r requirements.txt - python setup.py develop - pip install coverage python-coveralls diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9a64aaec78e5..910f89f07f54 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +Next Release (TBD) +================== + +* feature:``--generate-cli-skeleton``: Generates a JSON skeleton to fill out + and be used as input to ``--cli-input-json``. + (`issue 963 <https://github.com/aws/aws-cli/pull/963>`_) +* feature:``--cli-input-json``: Runs an operation using a global JSON file + that supplies all of the operation's arguments. This JSON file can + be generated by ``--generate-cli-skeleton``. + (`issue 963 <https://github.com/aws/aws-cli/pull/963>`_) + + 1.5.4 ===== diff --git a/awscli/clidriver.py b/awscli/clidriver.py index 0dde5bde6b5c..48bdaff99e0f 100644 --- a/awscli/clidriver.py +++ b/awscli/clidriver.py @@ -440,6 +440,9 @@ def arg_table(self): def __call__(self, args, parsed_globals): # Once we know we're trying to call a particular operation # of a service we can go ahead and load the parameters. + event = 'before-building-argument-table-parser.%s.%s' % \ + (self._parent_name, self._name) + self._emit(event, argument_table=self.arg_table, args=args) operation_parser = self._create_operation_parser(self.arg_table) self._add_help(operation_parser) parsed_args, remaining = operation_parser.parse_known_args(args) @@ -457,8 +460,32 @@ def __call__(self, args, parsed_globals): parsed_globals=parsed_globals) call_parameters = self._build_call_parameters(parsed_args, self.arg_table) - return self._operation_caller.invoke( - self._operation_object, call_parameters, parsed_globals) + event = 'calling-command.%s.%s' % (self._parent_name, + self._name) + override = self._emit_first_non_none_response( + event, + call_parameters=call_parameters, + parsed_args=parsed_args, + parsed_globals=parsed_globals + ) + # There are two possible values for override. It can be some type + # of exception that will be raised if detected or it can represent + # the desired return code. Note that a return code of 0 represents + # a success. + if override is not None: + if isinstance(override, Exception): + # If the override value provided back is an exception then + # raise the exception + raise override + else: + # This is the value usually returned by the ``invoke()`` + # method of the operation caller. It represents the return + # code of the operation. + return override + else: + # No override value was supplied. + return self._operation_caller.invoke( + self._operation_object, call_parameters, parsed_globals) def create_help_command(self): return OperationHelpCommand( @@ -525,6 +552,10 @@ def _emit(self, name, **kwargs): session = self._service_object.session return session.emit(name, **kwargs) + def _emit_first_non_none_response(self, name, **kwargs): + session = self._service_object.session + return session.emit_first_non_none_response(name, **kwargs) + def _create_operation_parser(self, arg_table): parser = ArgTableArgParser(arg_table) return parser diff --git a/awscli/customizations/arguments.py b/awscli/customizations/arguments.py new file mode 100644 index 000000000000..ee47dbe778c2 --- /dev/null +++ b/awscli/customizations/arguments.py @@ -0,0 +1,53 @@ +# 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 awscli.arguments import CustomArgument + + +class OverrideRequiredArgsArgument(CustomArgument): + """An argument that if specified makes all other arguments not required + + By not required, it refers to not having an error thrown when the + parser does not find an argument that is required on the command line. + To obtain this argument's property of ignoring required arguments, + subclass from this class and fill out the ``ARG_DATA`` parameter as + described below. Note this class is really only useful for subclassing. + """ + + # ``ARG_DATA`` follows the same format as a member of ``ARG_TABLE`` in + # ``BasicCommand`` class as specified in + # ``awscli/customizations/commands.py``. + # + # For example, an ``ARG_DATA`` variable would be filled out as: + # + # ARG_DATA = + # {'name': 'my-argument', + # 'help_text': 'This is argument ensures the argument is specified' + # 'no other arguments are required'} + ARG_DATA = {'name': 'no-required-args'} + + def __init__(self, session): + self._session = session + self._register_argument_action() + super(OverrideRequiredArgsArgument, self).__init__(**self.ARG_DATA) + + def _register_argument_action(self): + self._session.register('before-building-argument-table-parser', + self.override_required_args) + + def override_required_args(self, argument_table, args, **kwargs): + name_in_cmdline = '--' + self.name + # Set all ``Argument`` objects in ``argument_table`` to not required + # if this argument's name is present in the command line. + if name_in_cmdline in args: + for arg_name in argument_table.keys(): + argument_table[arg_name].required = False diff --git a/awscli/customizations/cliinputjson.py b/awscli/customizations/cliinputjson.py new file mode 100644 index 000000000000..e986840eec44 --- /dev/null +++ b/awscli/customizations/cliinputjson.py @@ -0,0 +1,85 @@ +# 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 json + +from awscli.paramfile import get_paramfile +from awscli.argprocess import ParamError +from awscli.customizations.arguments import OverrideRequiredArgsArgument + + +def register_cli_input_json(cli): + cli.register('building-argument-table', add_cli_input_json) + + +def add_cli_input_json(operation, argument_table, **kwargs): + # This argument cannot support operations with streaming output which + # is designated by the argument name `outfile`. + if 'outfile' not in argument_table: + cli_input_json_argument = CliInputJSONArgument(operation) + cli_input_json_argument.add_to_arg_table(argument_table) + + +class CliInputJSONArgument(OverrideRequiredArgsArgument): + """This argument inputs a JSON string as the entire input for a command. + + Ideally, the value to this argument should be a filled out JSON file + generated by ``--generate-cli-skeleton``. The items in the JSON string + will not clobber other arguments entered into the command line. + """ + ARG_DATA = { + 'name': 'cli-input-json', + 'help_text': 'Performs service operation based on the JSON string ' + 'provided. The JSON string follows the format provided ' + 'by ``--generate-cli-skeleton``. If other arguments are ' + 'provided on the command line, it will not clobber their ' + 'values.' + } + + def __init__(self, operation_object): + self._operation_object = operation_object + super(CliInputJSONArgument, self).__init__( + self._operation_object.session) + + def _register_argument_action(self): + self._operation_object.session.register( + 'calling-command', self.add_to_call_parameters) + super(CliInputJSONArgument, self)._register_argument_action() + + def add_to_call_parameters(self, call_parameters, parsed_args, + parsed_globals, **kwargs): + + # Check if ``--cli-input-json`` was specified in the command line. + input_json = getattr(parsed_args, 'cli_input_json', None) + if input_json is not None: + # Retrieve the JSON from the file if needed. + retrieved_json = get_paramfile(input_json) + # Nothing was retrieved from the file. So assume the argument + # is already a JSON string. + if retrieved_json is None: + retrieved_json = input_json + try: + # Try to load the JSON string into a python dictionary + input_data = json.loads(retrieved_json) + except ValueError as e: + raise ParamError( + self.name, "Invalid JSON: %s\nJSON received: %s" + % (e, retrieved_json)) + # Add the members from the input JSON to the call parameters. + self._update_call_parameters(call_parameters, input_data) + + def _update_call_parameters(self, call_parameters, input_data): + for input_key in input_data.keys(): + # Only add the values to ``call_parameters`` if not already + # present. + if input_key not in call_parameters: + call_parameters[input_key] = input_data[input_key] diff --git a/awscli/customizations/cloudsearchdomain.py b/awscli/customizations/cloudsearchdomain.py index c21ce9d151c0..5aebcaf7fbd1 100644 --- a/awscli/customizations/cloudsearchdomain.py +++ b/awscli/customizations/cloudsearchdomain.py @@ -19,11 +19,11 @@ """ def register_cloudsearchdomain(cli): - cli.register('operation-args-parsed.cloudsearchdomain', - validate_endpoint_url) + cli.register_last('calling-command.cloudsearchdomain', + validate_endpoint_url) def validate_endpoint_url(parsed_globals, **kwargs): if parsed_globals.endpoint_url is None: - raise ValueError( + return ValueError( "--endpoint-url is required for cloudsearchdomain commands") diff --git a/awscli/customizations/ec2addcount.py b/awscli/customizations/ec2addcount.py index e16a881e923d..d96cd3d44835 100644 --- a/awscli/customizations/ec2addcount.py +++ b/awscli/customizations/ec2addcount.py @@ -40,6 +40,7 @@ def __init__(self, operation, name): self.argument_model = model.Shape('CountArgument', {'type': 'string'}) self._operation = operation self._name = name + self._required = False @property def cli_name(self): @@ -51,7 +52,11 @@ def cli_type_name(self): @property def required(self): - return False + return self._required + + @required.setter + def required(self, value): + self._required = value @property def documentation(self): diff --git a/awscli/customizations/ec2decryptpassword.py b/awscli/customizations/ec2decryptpassword.py index 64f775efa67c..0a27a347acb7 100644 --- a/awscli/customizations/ec2decryptpassword.py +++ b/awscli/customizations/ec2decryptpassword.py @@ -45,6 +45,7 @@ def __init__(self, operation, name): self._operation = operation self._name = name self._key_path = None + self._required = False @property def cli_type_name(self): @@ -52,7 +53,11 @@ def cli_type_name(self): @property def required(self): - return False + return self._required + + @required.setter + def required(self, value): + self._required = value @property def documentation(self): diff --git a/awscli/customizations/generatecliskeleton.py b/awscli/customizations/generatecliskeleton.py new file mode 100644 index 000000000000..8edb7822cdd3 --- /dev/null +++ b/awscli/customizations/generatecliskeleton.py @@ -0,0 +1,87 @@ +# 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 json +import sys + +from botocore.utils import ArgumentGenerator + +from awscli.customizations.arguments import OverrideRequiredArgsArgument + + +def register_generate_cli_skeleton(cli): + cli.register('building-argument-table', add_generate_skeleton) + + +def add_generate_skeleton(operation, argument_table, **kwargs): + # This argument cannot support operations with streaming output which + # is designated by the argument name `outfile`. + if 'outfile' not in argument_table: + generate_cli_skeleton_argument = GenerateCliSkeletonArgument(operation) + generate_cli_skeleton_argument.add_to_arg_table(argument_table) + + +class GenerateCliSkeletonArgument(OverrideRequiredArgsArgument): + """This argument writes a generated JSON skeleton to stdout + + The argument, if present in the command line, will prevent the intended + command from taking place. Instead, it will generate a JSON skeleton and + print it to standard output. This JSON skeleton then can be filled out + and can be used as input to ``--input-cli-json`` in order to run the + command with the filled out JSON skeleton. + """ + ARG_DATA = { + 'name': 'generate-cli-skeleton', + 'help_text': 'Prints a sample input JSON to standard output. Note the ' + 'specified operation is not run if this argument is ' + 'specified. The sample input can be used as an argument ' + 'for ``--cli-input-json``.', + 'action': 'store_true', + 'group_name': 'generate_cli_skeleton' + } + + def __init__(self, operation_object): + self._operation_object = operation_object + super(GenerateCliSkeletonArgument, self).__init__( + self._operation_object.session) + + def _register_argument_action(self): + self._operation_object.session.register( + 'calling-command.*', self.generate_json_skeleton) + super(GenerateCliSkeletonArgument, self)._register_argument_action() + + def generate_json_skeleton(self, call_parameters, parsed_args, + parsed_globals, **kwargs): + + # Only perform the method if the ``--generate-cli-skeleton`` was + # included in the command line. + if getattr(parsed_args, 'generate_cli_skeleton', False): + + # Obtain the model of the operation + operation_model = self._operation_object.model + + # Generate the skeleton based on the ``input_shape``. + argument_generator = ArgumentGenerator() + operation_input_shape = operation_model.input_shape + # If the ``input_shape`` is ``None``, generate an empty + # dictionary. + if operation_input_shape is None: + skeleton = {} + else: + skeleton = argument_generator.generate_skeleton( + operation_input_shape) + + # Write the generated skeleton to standard output. + sys.stdout.write(json.dumps(skeleton, indent=4)) + sys.stdout.write('\n') + # This is the return code + return 0 diff --git a/awscli/customizations/paginate.py b/awscli/customizations/paginate.py index cfc6891092bb..fe2e84e0181d 100644 --- a/awscli/customizations/paginate.py +++ b/awscli/customizations/paginate.py @@ -140,6 +140,7 @@ def __init__(self, name, documentation, operation, parse_type): self._name = name self._documentation = documentation self._parse_type = parse_type + self._required = False @property def cli_name(self): @@ -151,7 +152,11 @@ def cli_type_name(self): @property def required(self): - return False + return self._required + + @required.setter + def required(self, value): + self._required = value @property def documentation(self): diff --git a/awscli/customizations/streamingoutputarg.py b/awscli/customizations/streamingoutputarg.py index 8f61c139915e..536e640e64c2 100644 --- a/awscli/customizations/streamingoutputarg.py +++ b/awscli/customizations/streamingoutputarg.py @@ -52,6 +52,7 @@ def __init__(self, response_key, operation, name, buffer_size=None): self._response_key = response_key self._output_file = None self._name = name + self._required = True @property def cli_name(self): @@ -66,7 +67,11 @@ def cli_type_name(self): @property def required(self): - return True + return self._required + + @required.setter + def required(self, value): + self._required = value @property def documentation(self): diff --git a/awscli/handlers.py b/awscli/handlers.py index 7eceb4ebcda3..e9d2ce941f60 100644 --- a/awscli/handlers.py +++ b/awscli/handlers.py @@ -48,6 +48,9 @@ from awscli.customizations.cloudsearchdomain import register_cloudsearchdomain from awscli.customizations.s3endpoint import register_s3_endpoint from awscli.customizations.s3errormsg import register_s3_error_msg +from awscli.customizations.cliinputjson import register_cli_input_json +from awscli.customizations.generatecliskeleton import \ + register_generate_cli_skeleton def awscli_initialize(event_handlers): @@ -70,6 +73,7 @@ def awscli_initialize(event_handlers): # param_shorthand.add_example_fn) event_handlers.register('doc-examples.*.*', add_examples) + register_cli_input_json(event_handlers) event_handlers.register('building-argument-table.s3api.*', add_streaming_output_arg) event_handlers.register('building-argument-table.ec2.run-instances', @@ -100,3 +104,4 @@ def awscli_initialize(event_handlers): emr_initialize(event_handlers) register_cloudsearchdomain(event_handlers) register_s3_endpoint(event_handlers) + register_generate_cli_skeleton(event_handlers) diff --git a/tests/integration/customizations/test_cliinputjson.py b/tests/integration/customizations/test_cliinputjson.py new file mode 100644 index 000000000000..d5538a204af8 --- /dev/null +++ b/tests/integration/customizations/test_cliinputjson.py @@ -0,0 +1,118 @@ +# 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 os +import time +import tempfile +import random +import shutil + +import botocore.session + +from awscli.testutils import unittest, aws + + +class TestIntegCliInputJson(unittest.TestCase): + """This tests to see if a service properly uses the generated input JSON. + + The s3 service was chosen becuase its operations do not take a lot of time. + These tests are essentially smoke tests. They are testing that the + ``--cli-input-json`` works. It is by no means exhaustive. + """ + def setUp(self): + self.session = botocore.session.get_session() + self.region = 'us-west-2' + + # Set up a s3 bucket. + self.s3 = self.session.create_client('s3', region_name=self.region) + self.bucket_name = 'cliinputjsontest%s-%s' % ( + int(time.time()), random.randint(1, 1000000)) + self.s3.create_bucket( + Bucket=self.bucket_name, + CreateBucketConfiguration={'LocationConstraint': self.region} + ) + + # Add an object to the bucket. + self.obj_name = 'foo' + self.s3.put_object( + Bucket=self.bucket_name, + Key=self.obj_name, + Body='bar' + ) + + # Create a temporary sample input json file. + self.input_json = '{"Bucket": "%s", "Key": "%s"}' % \ + (self.bucket_name, self.obj_name) + + self.temp_dir = tempfile.mkdtemp() + self.temp_file = os.path.join(self.temp_dir, 'foo.json') + with open(self.temp_file, 'w') as f: + f.write(self.input_json) + + def tearDown(self): + shutil.rmtree(self.temp_dir) + self.s3.delete_object( + Bucket=self.bucket_name, + Key=self.obj_name + ) + self.s3.delete_bucket(Bucket=self.bucket_name) + + def test_cli_input_json_no_exta_args(self): + # Run a head command using the input json + p = aws('s3api head-object --cli-input-json file://%s --region %s' + % (self.temp_file, self.region)) + # The head object command should find the object specified by the + # input json file. + self.assertEqual(p.rc, 0) + + def test_cli_input_json_exta_args(self): + # Check that the object can be found. + p = aws('s3api head-object --cli-input-json file://%s --region %s' + % (self.temp_file, self.region)) + self.assertEqual(p.rc, 0) + + # Override the ``key`` argument. Should produce a failure because + # the key ``bar`` does not exist. + p = aws('s3api head-object --key bar --cli-input-json file://%s ' + '--region %s' + % (self.temp_file, self.region)) + self.assertEqual(p.rc, 255) + self.assertIn('Not Found', p.stderr) + + def test_cli_input_json_not_from_file(self): + # Check that the input json can be used without having to use a file. + p = aws( + 's3api head-object --region %s --cli-input-json ' + '\'{"Bucket": "%s", "Key": "%s"}\'' % + (self.region, self.bucket_name, self.obj_name)) + self.assertEqual(p.rc, 0) + + def test_cli_input_json_missing_required(self): + # Check that the operation properly throws an error if the json is + # missing any required arguments and the argument is not on the + # command line. + p = aws( + 's3api head-object --region %s --cli-input-json ' + '\'{"Key": "%s"}\'' % + (self.region, self.obj_name)) + self.assertEqual(p.rc, 255) + self.assertIn('Missing', p.stderr) + + def test_cli_input_json_has_extra_unknown_args(self): + # Check that the operation properly throws an error if the json + # has an extra argument that is not defined by the model. + p = aws( + 's3api head-object --region %s --cli-input-json ' + '\'{"Bucket": "%s", "Key": "%s", "Foo": "bar"}\'' % + (self.region, self.bucket_name, self.obj_name)) + self.assertEqual(p.rc, 255) + self.assertIn('Unknown', p.stderr) diff --git a/tests/integration/customizations/test_generatecliskeleton.py b/tests/integration/customizations/test_generatecliskeleton.py new file mode 100644 index 000000000000..b089c001a098 --- /dev/null +++ b/tests/integration/customizations/test_generatecliskeleton.py @@ -0,0 +1,84 @@ +# 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 json + +from nose.tools import assert_equal + +from awscli.clidriver import create_clidriver +from awscli.testutils import unittest, aws + + +def test_can_generate_skeletons_for_all_service_comands(): + driver = create_clidriver() + help_command = driver.create_help_command() + for command_name, command_obj in help_command.command_table.items(): + sub_help = command_obj.create_help_command() + # This avoids command objects like ``PreviewModeCommand`` that + # do not exhibit any visible functionality (i.e. provides a command + # for the CLI). + if hasattr(sub_help, 'command_table'): + for sub_name, sub_command in sub_help.command_table.items(): + op_help = sub_command.create_help_command() + arg_table = op_help.arg_table + if 'generate-cli-skeleton' in arg_table: + yield _test_gen_skeleton, command_name, sub_name + + +def _test_gen_skeleton(command_name, operation_name): + p = aws('%s %s --generate-cli-skeleton' % (command_name, operation_name)) + assert_equal(p.rc, 0, 'Received non zero RC (%s) for command: %s %s' + % (p.rc, command_name, operation_name)) + try: + parsed = json.loads(p.stdout) + except ValueError as e: + raise AssertionError( + "Could not generate CLI skeleton for command: %s %s\n" + "stdout:\n%s\n" + "stderr:\n%s\n" % (command_name, operation_name)) + + +class TestIntegGenerateCliSkeleton(unittest.TestCase): + """This tests various services to see if the generated skeleton is correct + + The operations and services selected are arbitrary. Tried to pick + operations that do not have many input options for the sake of readablity + and maintenance. These are essentially smoke tests. It is not trying to + test the different types of input shapes that can be generated in the + skeleton. It is only testing wheter the skeleton generator argument works + for various services. + """ + def test_generate_cli_skeleton_s3api(self): + p = aws('s3api delete-object --generate-cli-skeleton') + self.assertEqual(p.rc, 0) + self.assertEqual( + p.stdout, + '{\n "Bucket": "", \n "Key": "", \n "MFA": "", \n ' + '"VersionId": ""\n}\n' + ) + + def test_generate_cli_skeleton_sqs(self): + p = aws('sqs change-message-visibility --generate-cli-skeleton') + self.assertEqual(p.rc, 0) + self.assertEqual( + p.stdout, + '{\n "QueueUrl": "", \n "ReceiptHandle": "", \n ' + '"VisibilityTimeout": 0\n}\n' + ) + + def test_generate_cli_skeleton_iam(self): + p = aws('iam create-group --generate-cli-skeleton') + self.assertEqual(p.rc, 0) + self.assertEqual( + p.stdout, + '{\n "Path": "", \n "GroupName": ""\n}\n' + ) diff --git a/tests/unit/customizations/test_arguments.py b/tests/unit/customizations/test_arguments.py new file mode 100644 index 000000000000..ade75bf70545 --- /dev/null +++ b/tests/unit/customizations/test_arguments.py @@ -0,0 +1,45 @@ +# 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 +from awscli.customizations.arguments import OverrideRequiredArgsArgument + + +class TestOverrideRequiredArgsArgument(unittest.TestCase): + def setUp(self): + self.session = mock.Mock() + self.argument = OverrideRequiredArgsArgument(self.session) + + # Set up a sample argument_table + self.argument_table = {} + self.mock_arg = mock.Mock() + self.mock_arg.required = True + self.argument_table['mock-arg'] = self.mock_arg + + def test_register_argument_action(self): + register_args = self.session.register.call_args + self.assertEqual(register_args[0][0], + 'before-building-argument-table-parser') + self.assertEqual(register_args[0][1], + self.argument.override_required_args) + + def test_override_required_args_if_in_cmdline(self): + args = ['--no-required-args'] + self.argument.override_required_args(self.argument_table, args) + self.assertFalse(self.mock_arg.required) + + def test_no_override_required_args_if_not_in_cmdline(self): + args = [] + self.argument.override_required_args(self.argument_table, args) + self.assertTrue(self.mock_arg.required) diff --git a/tests/unit/customizations/test_cliinputjson.py b/tests/unit/customizations/test_cliinputjson.py new file mode 100644 index 000000000000..b8c0240357bc --- /dev/null +++ b/tests/unit/customizations/test_cliinputjson.py @@ -0,0 +1,101 @@ +# 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 +import os +import shutil +import tempfile + +from awscli.testutils import unittest +from awscli.argprocess import ParamError +from awscli.customizations.cliinputjson import CliInputJSONArgument + + +class TestCliInputJSONArgument(unittest.TestCase): + def setUp(self): + self.operation_object = mock.Mock() + self.argument = CliInputJSONArgument(self.operation_object) + + # Create the various forms the data could come in. The two main forms + # are as a string and or as a path to a file. + self.input_json = '{"A": "foo", "B": "bar"}' + + # Make a temporary file + self.temp_dir = tempfile.mkdtemp() + self.temp_file = os.path.join(self.temp_dir, 'foo.json') + with open(self.temp_file, 'w') as f: + f.write(self.input_json) + + def tearDown(self): + shutil.rmtree(self.temp_dir) + + def test_register_argument_action(self): + register_args = self.operation_object.session.register.call_args_list + self.assertEqual(register_args[0][0][0], 'calling-command') + self.assertEqual(register_args[0][0][1], + self.argument.add_to_call_parameters) + + def test_add_to_call_parameters_no_file(self): + parsed_args = mock.Mock() + # Make the value a JSON string + parsed_args.cli_input_json = self.input_json + call_parameters = {} + self.argument.add_to_call_parameters( + service_operation=None, call_parameters=call_parameters, + parsed_args=parsed_args, parsed_globals=None + ) + self.assertEqual(call_parameters, {'A': 'foo', 'B': 'bar'}) + + def test_add_to_call_parameters_with_file(self): + parsed_args = mock.Mock() + # Make the value a file with JSON located inside. + parsed_args.cli_input_json = 'file://' + self.temp_file + call_parameters = {} + self.argument.add_to_call_parameters( + service_operation=None, call_parameters=call_parameters, + parsed_args=parsed_args, parsed_globals=None + ) + self.assertEqual(call_parameters, {'A': 'foo', 'B': 'bar'}) + + def test_add_to_call_parameters_bad_json(self): + parsed_args = mock.Mock() + # Create a bad JSON input + parsed_args.cli_input_json = self.input_json + ',' + call_parameters = {} + with self.assertRaises(ParamError): + self.argument.add_to_call_parameters( + service_operation=None, call_parameters=call_parameters, + parsed_args=parsed_args, parsed_globals=None + ) + + def test_add_to_call_parameters_no_clobber(self): + parsed_args = mock.Mock() + parsed_args.cli_input_json = self.input_json + # The value for ``A`` should not be clobbered by the input JSON + call_parameters = {'A': 'baz'} + self.argument.add_to_call_parameters( + service_operation=None, call_parameters=call_parameters, + parsed_args=parsed_args, parsed_globals=None + ) + self.assertEqual(call_parameters, {'A': 'baz', 'B': 'bar'}) + + def test_no_add_to_call_parameters(self): + parsed_args = mock.Mock() + parsed_args.cli_input_json = None + call_parameters = {'A': 'baz'} + self.argument.add_to_call_parameters( + service_operation=None, call_parameters=call_parameters, + parsed_args=parsed_args, parsed_globals=None + ) + # Nothing should have been added to the call parameters because + # ``cli_input_json`` is not in the ``parsed_args`` + self.assertEqual(call_parameters, {'A': 'baz'}) diff --git a/tests/unit/customizations/test_cloudsearchdomain.py b/tests/unit/customizations/test_cloudsearchdomain.py index e545af32fd41..04eb230f423c 100644 --- a/tests/unit/customizations/test_cloudsearchdomain.py +++ b/tests/unit/customizations/test_cloudsearchdomain.py @@ -59,8 +59,9 @@ class TestCloudsearchDomainHandler(unittest.TestCase): def test_validate_endpoint_url_is_none(self): parsed_globals = mock.Mock() parsed_globals.endpoint_url = None - with self.assertRaises(ValueError): - validate_endpoint_url(parsed_globals) + # Method should return instantiated exception. + self.assertTrue(isinstance(validate_endpoint_url(parsed_globals), + ValueError)) if __name__ == "__main__": diff --git a/tests/unit/customizations/test_generatecliskeleton.py b/tests/unit/customizations/test_generatecliskeleton.py new file mode 100644 index 000000000000..161ee9c40a55 --- /dev/null +++ b/tests/unit/customizations/test_generatecliskeleton.py @@ -0,0 +1,96 @@ +# 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 six +import mock + +from botocore.model import DenormalizedStructureBuilder + +from awscli.testutils import unittest +from awscli.customizations.generatecliskeleton import \ + GenerateCliSkeletonArgument + + +class TestGenerateCliSkeleton(unittest.TestCase): + def setUp(self): + self.operation_object = mock.Mock() + self.argument = GenerateCliSkeletonArgument(self.operation_object) + + # Create a mock service operation object + self.service_operation = mock.Mock() + + # Make an arbitrary input model shape. + self.input_shape = { + 'A': { + 'type': 'structure', + 'members': { + 'B': {'type': 'string'}, + } + } + } + shape = DenormalizedStructureBuilder().with_members( + self.input_shape).build_model() + self.operation_object.model.input_shape = shape + + # This is what the json should should look like after being + # generated to standard output. + self.ref_json_output = \ + '{\n "A": {\n "B": ""\n }\n}\n' + + def test_register_argument_action(self): + register_args = self.operation_object.session.register.call_args_list + self.assertEqual(register_args[0][0][0], 'calling-command.*') + self.assertEqual(register_args[0][0][1], + self.argument.generate_json_skeleton) + + def test_generate_json_skeleton(self): + parsed_args = mock.Mock() + parsed_args.generate_cli_skeleton = True + with mock.patch('sys.stdout', six.StringIO()) as mock_stdout: + rc = self.argument.generate_json_skeleton( + service_operation=self.service_operation, call_parameters=None, + parsed_args=parsed_args, parsed_globals=None + ) + # Ensure the contents printed to standard output are correct. + self.assertEqual(self.ref_json_output, mock_stdout.getvalue()) + # Ensure it is the correct return code of zero. + self.assertEqual(rc, 0) + + def test_no_generate_json_skeleton(self): + parsed_args = mock.Mock() + parsed_args.generate_cli_skeleton = False + with mock.patch('sys.stdout', six.StringIO()) as mock_stdout: + rc = self.argument.generate_json_skeleton( + service_operation=self.service_operation, call_parameters=None, + parsed_args=parsed_args, parsed_globals=None + ) + # Ensure nothing is printed to standard output + self.assertEqual('', mock_stdout.getvalue()) + # Ensure nothing is returned because it was never called. + self.assertEqual(rc, None) + + + def test_generate_json_skeleton_no_input_shape(self): + parsed_args = mock.Mock() + parsed_args.generate_cli_skeleton = True + # Set the input shape to ``None``. + self.operation_object.model.input_shape = None + with mock.patch('sys.stdout', six.StringIO()) as mock_stdout: + rc = self.argument.generate_json_skeleton( + service_operation=self.service_operation, call_parameters=None, + parsed_args=parsed_args, parsed_globals=None + ) + # Ensure the contents printed to standard output are correct, + # which should be an empty dictionary. + self.assertEqual('{}\n', mock_stdout.getvalue()) + # Ensure it is the correct return code of zero. + self.assertEqual(rc, 0) diff --git a/tests/unit/test_clidriver.py b/tests/unit/test_clidriver.py index 9c1512f617c0..4e16303d2142 100644 --- a/tests/unit/test_clidriver.py +++ b/tests/unit/test_clidriver.py @@ -245,10 +245,12 @@ def test_expected_events_are_emitted_in_order(self): 'top-level-args-parsed', 'building-command-table.s3', 'building-argument-table.s3.list-objects', + 'before-building-argument-table-parser.s3.list-objects', 'operation-args-parsed.s3.list-objects', 'load-cli-arg.s3.list-objects.bucket', 'process-cli-arg.s3.list-objects', 'load-cli-arg.s3.list-objects.key', + 'calling-command.s3.list-objects' ]) def test_create_help_command(self): @@ -619,6 +621,34 @@ def raise_exception(*args, **kwargs): 'Unable to locate credentials. ' 'You can configure credentials by running "aws configure".') + def test_override_calling_command(self): + self.driver = create_clidriver() + + # Make a function that will return an override such that its value + # is used over whatever is returned by the invoker which is usually + # zero. + def override_with_rc(**kwargs): + return 20 + + self.driver.session.register('calling-command', override_with_rc) + rc = self.driver.main('ec2 describe-instances'.split()) + # Check that the overriden rc is as expected. + self.assertEqual(rc, 20) + + def test_override_calling_command_error(self): + self.driver = create_clidriver() + + # Make a function that will return an error. The handler will cause + # an error to be returned and later raised. + def override_with_error(**kwargs): + return ValueError() + + self.driver.session.register('calling-command', override_with_error) + # An exception should be thrown as a result of the handler, which + # will result in 255 rc. + rc = self.driver.main('ec2 describe-instances'.split()) + self.assertEqual(rc, 255) + class TestHTTPParamFileDoesNotExist(BaseAWSCommandParamsTest): diff --git a/tests/unit/test_completer.py b/tests/unit/test_completer.py index bd41e0e0f063..3839171d0b32 100644 --- a/tests/unit/test_completer.py +++ b/tests/unit/test_completer.py @@ -62,7 +62,8 @@ set(['--filters', '--dry-run', '--no-dry-run', '--endpoint-url', '--no-verify-ssl', '--no-paginate', '--no-sign-request', '--output', '--profile', '--starting-token', '--max-items', - '--region', '--version', '--color', '--query', '--page-size'])), + '--region', '--version', '--color', '--query', '--page-size', + '--generate-cli-skeleton', '--cli-input-json'])), ('aws s3', -1, set(['cp', 'mv', 'rm', 'mb', 'rb', 'ls', 'sync', 'website'])), ('aws s3 m', -1, set(['mv', 'mb'])), ('aws s3 cp -', -1, set(['--no-guess-mime-type', '--dryrun',