Repository: boto/botocore
Pull request #839 (2016-03-08 17:55:22)

Add ability to alias parameters
Fixes parameters that shadow reserved keywords in python and inconsistent
parameter names in ec2.

Fixes https://github.com/boto/botocore/issues/772

As to implementation, I followed a similar format (just a tad different) as the CLI so if the alias needed to be in both the CLI and boto3, we could import the dictionary in botocore and modify the ``HIDDEN_ALIAS`` dictionary in the CLI or just copy and paste entities in that dictionary from botocore to the CLI. I went with this path because it required less work and less of a wider impact in the code base then the ``all_member`` route we discussed . Also it would not be difficult for the CLI to pick up these aliases as it already has this functionality.

cc @jamesls @JordonPhillips 

jamesls: Looks good, just had a few questions/comments.

Patch:
diff --git a/botocore/exceptions.py b/botocore/exceptions.py
index 1b0031fcc..1f29400b9 100644
--- a/botocore/exceptions.py
+++ b/botocore/exceptions.py
@@ -241,6 +241,20 @@ class UnknownParameterError(ValidationError):
     )
 
 
+class AliasConflictParameterError(ValidationError):
+    """
+    Error when an alias is provided for a parameter as well as the original.
+
+    :ivar original: The name of the original parameter.
+    :ivar alias: The name of the alias
+    :ivar operation: The name of the operation.
+    """
+    fmt = (
+        "Parameter '{original}' and its alias '{alias}' were provided "
+        "for operation {operation}.  Only one of them may be used."
+    )
+
+
 class UnknownServiceStyle(BotoCoreError):
     """
     Unknown style of service invocation.
diff --git a/botocore/handlers.py b/botocore/handlers.py
index 7a384e64f..3af1ed548 100644
--- a/botocore/handlers.py
+++ b/botocore/handlers.py
@@ -31,6 +31,7 @@ from botocore.docs.utils import AppendParamDocumentation
 from botocore.signers import add_generate_presigned_url
 from botocore.signers import add_generate_presigned_post
 from botocore.exceptions import ParamValidationError
+from botocore.exceptions import AliasConflictParameterError
 from botocore.exceptions import UnsupportedTLSVersionWarning
 from botocore.utils import percent_encode, SAFE_CHARS
 
@@ -628,6 +629,87 @@ def convert_body_to_file_like_object(params, **kwargs):
             params['Body'] = six.BytesIO(params['Body'])
 
 
+def _add_parameter_aliases(handler_list):
+    # Mapping of original parameter to parameter alias.
+    # The key is <service>.<operation>.parameter
+    # The first part of the key is used for event registration.
+    # The last part is the original parameter name and the value is the
+    # alias to expose in documentation.
+    aliases = {
+        'ec2.*.Filter': 'Filters',
+        'logs.CreateExportTask.from': 'fromTime',
+        'cloudsearchdomain.Search.return': 'returnFields'
+    }
+
+    for original, new_name in aliases.items():
+        event_portion, original_name = original.rsplit('.', 1)
+        parameter_alias = ParameterAlias(original_name, new_name)
+
+        # Add the handlers to the list of handlers.
+        # One handler is to handle when users provide the alias.
+        # The other handler is to update the documentation to show only
+        # the alias.
+        parameter_build_event_handler_tuple = (
+            'before-parameter-build.' + event_portion,
+            parameter_alias.alias_parameter_in_call,
+            REGISTER_FIRST
+        )
+        docs_event_handler_tuple = (
+            'docs.*.' + event_portion + '.complete-section',
+            parameter_alias.alias_parameter_in_documentation)
+        handler_list.append(parameter_build_event_handler_tuple)
+        handler_list.append(docs_event_handler_tuple)
+
+
+class ParameterAlias(object):
+    def __init__(self, original_name, alias_name):
+        self._original_name = original_name
+        self._alias_name = alias_name
+
+    def alias_parameter_in_call(self, params, model, **kwargs):
+        if model.input_shape:
+            # Only consider accepting the alias if it is modeled in the
+            # input shape.
+            if self._original_name in model.input_shape.members:
+                if self._alias_name in params:
+                    if self._original_name in params:
+                        raise AliasConflictParameterError(
+                            original=self._original_name,
+                            alias=self._alias_name,
+                            operation=model.name
+                        )
+                    # Remove the alias parameter value and use the old name
+                    # instead.
+                    params[self._original_name] = params.pop(self._alias_name)
+
+    def alias_parameter_in_documentation(self, event_name, section, **kwargs):
+        if event_name.startswith('docs.request-params'):
+            if self._original_name not in section.available_sections:
+                return
+            # Replace the name for parameter type
+            param_section = section.get_section(self._original_name)
+            param_type_section = param_section.get_section('param-type')
+            self._replace_content(param_type_section)
+
+            # Replace the name for the parameter description
+            param_name_section = param_section.get_section('param-name')
+            self._replace_content(param_name_section)
+        elif event_name.startswith('docs.request-example'):
+            section = section.get_section('structure-value')
+            if self._original_name not in section.available_sections:
+                return
+            # Replace the name for the example
+            param_section = section.get_section(self._original_name)
+            self._replace_content(param_section)
+
+    def _replace_content(self, section):
+        content = section.getvalue().decode('utf-8')
+        updated_content = content.replace(
+            self._original_name, self._alias_name)
+        section.clear_text()
+        section.write(updated_content)
+
+
 # This is a list of (event_name, handler).
 # When a Session is created, everything in this list will be
 # automatically registered with that Session.
@@ -751,3 +833,4 @@ BUILTIN_HANDLERS = [
           'PutBucketTagging', 'PutBucketVersioning', 'PutBucketWebsite',
           'PutObjectAcl']).hide_param)
 ]
+_add_parameter_aliases(BUILTIN_HANDLERS)


Test patch:
diff --git a/tests/functional/docs/test_alias.py b/tests/functional/docs/test_alias.py
new file mode 100644
index 000000000..382011349
--- /dev/null
+++ b/tests/functional/docs/test_alias.py
@@ -0,0 +1,36 @@
+# Copyright 2016 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 tests.functional.docs import BaseDocsFunctionalTest
+from tests.functional.test_alias import ALIAS_CASES
+
+
+class TestAliasesDocumented(BaseDocsFunctionalTest):
+    def test_all_aliases_are_documented_correctly(self):
+        for case in ALIAS_CASES:
+            content = self.get_docstring_for_method(
+                case['service'], case['operation']).decode('utf-8')
+            new_name = case['new_name']
+            original_name = case['original_name']
+            param_name_template = ':param %s:'
+            param_type_template = ':type %s:'
+            param_example_template = '%s='
+
+            # Make sure the new parameters are in the documentation
+            # but the old names are not.
+            self.assertIn(param_name_template % new_name, content)
+            self.assertIn(param_type_template % new_name, content)
+            self.assertIn(param_example_template % new_name, content)
+
+            self.assertNotIn(param_name_template % original_name, content)
+            self.assertNotIn(param_type_template % original_name, content)
+            self.assertNotIn(param_example_template % original_name, content)
diff --git a/tests/functional/test_alias.py b/tests/functional/test_alias.py
new file mode 100644
index 000000000..f9bc5403b
--- /dev/null
+++ b/tests/functional/test_alias.py
@@ -0,0 +1,84 @@
+# Copyright 2016 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
+from botocore.stub import Stubber
+from botocore.exceptions import ParamValidationError
+
+
+ALIAS_CASES = [
+    {
+        'service': 'ec2',
+        'operation': 'describe_flow_logs',
+        'original_name': 'Filter',
+        'new_name': 'Filters',
+        'parameter_value': [{'Name': 'traffic-type', 'Values': ['ACCEPT']}]
+    },
+    {
+        'service': 'cloudsearchdomain',
+        'operation': 'search',
+        'original_name': 'return',
+        'new_name': 'returnFields',
+        'parameter_value': '_all_fields',
+        'extra_args': {'query': 'foo'}
+    },
+    {
+        'service': 'logs',
+        'operation': 'create_export_task',
+        'original_name': 'from',
+        'new_name': 'fromTime',
+        'parameter_value': 0,
+        'extra_args': {
+            'logGroupName': 'name',
+            'to': 10,
+            'destination': 'mybucket'
+        }
+    }
+]
+
+
+def test_can_use_alias():
+    session = botocore.session.get_session()
+    for case in ALIAS_CASES:
+        yield _can_use_parameter_in_client_call, session, case
+
+
+def test_can_use_original_name():
+    session = botocore.session.get_session()
+    for case in ALIAS_CASES:
+        yield _can_use_parameter_in_client_call, session, case, False
+
+
+def _can_use_parameter_in_client_call(session, case, use_alias=True):
+    client = session.create_client(
+        case['service'], region_name='us-east-1',
+        aws_access_key_id='foo', aws_secret_access_key='bar')
+
+    stubber = Stubber(client)
+    stubber.activate()
+    operation = case['operation']
+    params = case.get('extra_args', {})
+    params = params.copy()
+    param_name = case['original_name']
+    if use_alias:
+        param_name = case['new_name']
+    params[param_name] = case['parameter_value']
+    stubbed_response = case.get('stubbed_response', {})
+    stubber.add_response(operation, stubbed_response)
+    try:
+        getattr(client, operation)(**params)
+    except ParamValidationError as e:
+        raise AssertionError(
+            'Expecting %s to be valid parameter for %s.%s but received '
+            '%s.' % (
+                case['new_name'], case['service'], case['operation'], e)
+        )
diff --git a/tests/unit/test_handlers.py b/tests/unit/test_handlers.py
index 9d56765e2..046e0cbd5 100644
--- a/tests/unit/test_handlers.py
+++ b/tests/unit/test_handlers.py
@@ -11,7 +11,7 @@
 # ANY KIND, either express or implied. See the License for the specific
 # language governing permissions and limitations under the License.
 
-from tests import BaseSessionTest
+from tests import unittest, BaseSessionTest
 
 import base64
 import mock
@@ -21,9 +21,15 @@ import os
 import botocore
 import botocore.session
 from botocore.exceptions import ParamValidationError, MD5UnavailableError
+from botocore.exceptions import AliasConflictParameterError
 from botocore.awsrequest import AWSRequest
 from botocore.compat import quote, six
+from botocore.docs.bcdoc.restdoc import DocumentStructure
+from botocore.docs.params import RequestParamsDocumenter
+from botocore.docs.example import RequestExampleDocumenter
+from botocore.hooks import HierarchicalEmitter
 from botocore.model import OperationModel, ServiceModel
+from botocore.model import DenormalizedStructureBuilder
 from botocore.signers import RequestSigner
 from botocore.credentials import Credentials
 from botocore import handlers
@@ -275,10 +281,9 @@ class TestHandlers(BaseSessionTest):
     def test_run_instances_userdata(self):
         user_data = 'This is a test'
         b64_user_data = base64.b64encode(six.b(user_data)).decode('utf-8')
-        event = 'before-parameter-build.ec2.RunInstances'
         params = dict(ImageId='img-12345678',
                       MinCount=1, MaxCount=5, UserData=user_data)
-        self.session.emit(event, params=params)
+        handlers.base64_encode_user_data(params=params)
         result = {'ImageId': 'img-12345678',
                   'MinCount': 1,
                   'MaxCount': 5,
@@ -291,10 +296,9 @@ class TestHandlers(BaseSessionTest):
         # user data.
         user_data = b'\xc7\xa9This is a test'
         b64_user_data = base64.b64encode(user_data).decode('utf-8')
-        event = 'before-parameter-build.ec2.RunInstances'
         params = dict(ImageId='img-12345678',
                       MinCount=1, MaxCount=5, UserData=user_data)
-        self.session.emit(event, params=params)
+        handlers.base64_encode_user_data(params=params)
         result = {'ImageId': 'img-12345678',
                   'MinCount': 1,
                   'MaxCount': 5,
@@ -804,3 +808,86 @@ class TestAddMD5(BaseMD5Test):
         self.assertEqual(
             request_dict['headers']['Content-MD5'],
             'OFj2IjCsPJFfMAxmQxLGPw==')
+
+
+class TestParameterAlias(unittest.TestCase):
+    def setUp(self):
+        self.original_name = 'original'
+        self.alias_name = 'alias'
+        self.parameter_alias = handlers.ParameterAlias(
+            self.original_name, self.alias_name)
+
+        self.operation_model = mock.Mock()
+        request_shape = DenormalizedStructureBuilder().with_members(
+            {self.original_name: {'type': 'string'}}).build_model()
+        self.operation_model.input_shape = request_shape
+        self.sample_section = DocumentStructure('')
+        self.event_emitter = HierarchicalEmitter()
+
+    def test_alias_parameter_in_call(self):
+        value = 'value'
+        params = {self.alias_name: value}
+        self.parameter_alias.alias_parameter_in_call(
+            params, self.operation_model)
+        self.assertEqual(params, {self.original_name: value})
+
+    def test_alias_parameter_and_original_in_call(self):
+        params = {
+            self.original_name: 'orginal_value',
+            self.alias_name: 'alias_value'
+        }
+        with self.assertRaises(AliasConflictParameterError):
+            self.parameter_alias.alias_parameter_in_call(
+                params, self.operation_model)
+
+    def test_alias_parameter_in_call_does_not_touch_original(self):
+        value = 'value'
+        params = {self.original_name: value}
+        self.parameter_alias.alias_parameter_in_call(
+            params, self.operation_model)
+        self.assertEqual(params, {self.original_name: value})
+
+    def test_does_not_alias_parameter_for_no_input_shape(self):
+        value = 'value'
+        params = {self.alias_name: value}
+        self.operation_model.input_shape = None
+        self.parameter_alias.alias_parameter_in_call(
+            params, self.operation_model)
+        self.assertEqual(params, {self.alias_name: value})
+
+    def test_does_not_alias_parameter_for_not_modeled_member(self):
+        value = 'value'
+        params = {self.alias_name: value}
+
+        request_shape = DenormalizedStructureBuilder().with_members(
+            {'foo': {'type': 'string'}}).build_model()
+        self.operation_model.input_shape = request_shape
+        self.parameter_alias.alias_parameter_in_call(
+            params, self.operation_model)
+        self.assertEqual(params, {self.alias_name: value})
+
+    def test_alias_parameter_in_documentation_request_params(self):
+        RequestParamsDocumenter(
+            'myservice', 'myoperation', self.event_emitter).document_params(
+                self.sample_section, self.operation_model.input_shape)
+        self.parameter_alias.alias_parameter_in_documentation(
+            'docs.request-params.myservice.myoperation.complete-section',
+            self.sample_section
+        )
+        contents = self.sample_section.flush_structure().decode('utf-8')
+        self.assertIn(':type ' + self.alias_name + ':',  contents)
+        self.assertIn(':param ' + self.alias_name + ':',  contents)
+        self.assertNotIn(':type ' + self.original_name + ':',  contents)
+        self.assertNotIn(':param ' + self.original_name + ':',  contents)
+
+    def test_alias_parameter_in_documentation_request_example(self):
+        RequestExampleDocumenter(
+            'myservice', 'myoperation', self.event_emitter).document_example(
+                self.sample_section, self.operation_model.input_shape)
+        self.parameter_alias.alias_parameter_in_documentation(
+            'docs.request-example.myservice.myoperation.complete-section',
+            self.sample_section
+        )
+        contents = self.sample_section.flush_structure().decode('utf-8')
+        self.assertIn(self.alias_name + '=',  contents)
+        self.assertNotIn(self.original_name + '=', contents)