Repository: boto/botocore
Pull request #448 (2015-02-03 22:30:58)

Implement opt-in unsigned requests for clients.
This change makes it possible to override the signature version at client
creation time using a parameter. It also introduces the concept of an
advanced configuration object for clients:

```python
import botocore
from botocore.client import Config

config = Config(signature_version=botocore.UNSIGNED)
s3 = session.create_client('s3', config=config)
```

This does **not** move any existing parameters into the advanced
configuration object, and thus is not yet a breaking change. It
does include the following:

* Adds `botocore.UNSIGNED` as a sentinel to disable signing
* Updates `botocore.handlers.disable_signing` to use `botocore.UNSIGNED`
* Adds a `botocore.client.Config` object to store signature version
* Modifies client creation to use this new config
* Updated / new unit tests

cc @jamesls @kyleknap 


Patch:
diff --git a/botocore/__init__.py b/botocore/__init__.py
index 96fbb65a9..cd5259b21 100644
--- a/botocore/__init__.py
+++ b/botocore/__init__.py
@@ -51,6 +51,9 @@ ScalarTypes = ('string', 'integer', 'boolean', 'timestamp', 'float', 'double')
 
 BOTOCORE_ROOT = os.path.dirname(os.path.abspath(__file__))
 
+# Used to specify anonymous (unsigned) request signature
+UNSIGNED = object()
+
 
 def xform_name(name, sep='_', _xform_cache=_xform_cache):
     """Convert camel case to a "pythonic" name.
diff --git a/botocore/client.py b/botocore/client.py
index 8ee369906..5386189ca 100644
--- a/botocore/client.py
+++ b/botocore/client.py
@@ -44,12 +44,13 @@ class ClientCreator(object):
 
     def create_client(self, service_name, region_name, is_secure=True,
                       endpoint_url=None, verify=None,
-                      credentials=None, scoped_config=None):
+                      credentials=None, scoped_config=None,
+                      client_config=None):
         service_model = self._load_service_model(service_name)
         cls = self.create_client_class(service_name)
         client_args = self._get_client_args(
             service_model, region_name, is_secure, endpoint_url,
-            verify, credentials, scoped_config)
+            verify, credentials, scoped_config, client_config)
         return cls(**client_args)
 
     def create_client_class(self, service_name):
@@ -209,7 +210,7 @@ class ClientCreator(object):
 
     def _get_client_args(self, service_model, region_name, is_secure,
                          endpoint_url, verify, credentials,
-                         scoped_config):
+                         scoped_config, client_config):
         # A client needs:
         #
         # * serializer
@@ -232,6 +233,9 @@ class ClientCreator(object):
             self._get_signature_version_and_region(
                 service_model, region_name, is_secure, scoped_config)
 
+        if client_config and client_config.signature_version is not None:
+            signature_version = client_config.signature_version
+
         signer = RequestSigner(service_model.service_name, region_name,
                                service_model.signing_name,
                                signature_version, credentials,
@@ -378,3 +382,15 @@ class ClientMeta(object):
     def __copy__(self):
         copied_events = copy.copy(self.events)
         return ClientMeta(copied_events)
+
+
+class Config(object):
+    """Advanced configuration for Botocore clients.
+
+    This class allows you to configure:
+
+        * Signature version
+
+    """
+    def __init__(self, signature_version=None):
+        self.signature_version = signature_version
diff --git a/botocore/handlers.py b/botocore/handlers.py
index 333e7f108..436b8f08f 100644
--- a/botocore/handlers.py
+++ b/botocore/handlers.py
@@ -27,6 +27,7 @@ from botocore.compat import urlsplit, urlunsplit, unquote, json, quote, six
 from botocore import retryhandler
 from botocore import utils
 from botocore import translate
+import botocore
 import botocore.auth
 
 
@@ -269,10 +270,9 @@ def _register_for_operations(config, session, service_name):
 def disable_signing(**kwargs):
     """
     This handler disables request signing by setting the signer
-    name to an empty string, similar to how the signature
-    overrides above work.
+    name to a special sentinel value.
     """
-    return ''
+    return botocore.UNSIGNED
 
 
 def add_expect_header(model, params, **kwargs):
diff --git a/botocore/session.py b/botocore/session.py
index a39803e28..a6aabe84f 100644
--- a/botocore/session.py
+++ b/botocore/session.py
@@ -750,7 +750,7 @@ class Session(object):
     def create_client(self, service_name, region_name=None, api_version=None,
                       use_ssl=True, verify=None, endpoint_url=None,
                       aws_access_key_id=None, aws_secret_access_key=None,
-                      aws_session_token=None):
+                      aws_session_token=None, config=None):
         """Create a botocore client.
 
         :type service_name: string
@@ -805,6 +805,9 @@ class Session(object):
         :param aws_session_token: The session token to use when creating
             the client.  Same semantics as aws_access_key_id above.
 
+        :type config: botocore.client.Config
+        :param config: Advanced client configuration options.
+
         :rtype: botocore.client.BaseClient
         :return: A botocore client instance
 
@@ -828,7 +831,8 @@ class Session(object):
             response_parser_factory)
         client = client_creator.create_client(
             service_name, region_name, use_ssl, endpoint_url, verify,
-            credentials, scoped_config=self.get_scoped_config())
+            credentials, scoped_config=self.get_scoped_config(),
+            client_config=config)
         return client
 
 
diff --git a/botocore/signers.py b/botocore/signers.py
index 4b54373b2..815b1054f 100644
--- a/botocore/signers.py
+++ b/botocore/signers.py
@@ -11,6 +11,7 @@
 # ANY KIND, either express or implied. See the License for the specific
 # language governing permissions and limitations under the License.
 
+import botocore
 import botocore.auth
 
 from botocore.exceptions import UnknownSignatureVersionError
@@ -86,7 +87,7 @@ class RequestSigner(object):
             signature_version=signature_version, request_signer=self)
 
         # Sign the request if the signature version isn't None or blank
-        if signature_version:
+        if signature_version != botocore.UNSIGNED:
             signer = self.get_auth(self._signing_name, self._region_name,
                                     signature_version)
             signer.add_auth(request=request)


Test patch:
diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py
index 76781c32f..3b3d25794 100644
--- a/tests/unit/test_client.py
+++ b/tests/unit/test_client.py
@@ -14,6 +14,7 @@
 from tests import unittest
 import mock
 
+import botocore
 from botocore import client
 from botocore import hooks
 from botocore.credentials import Credentials
@@ -100,7 +101,7 @@ class TestAutoGeneratedClient(unittest.TestCase):
             mock.ANY, mock.ANY, mock.ANY, 'v4', mock.ANY, mock.ANY)
 
     @mock.patch('botocore.client.RequestSigner')
-    def test_client_signature_override(self, request_signer):
+    def test_client_signature_override_config_file(self, request_signer):
         creator = self.create_client_creator()
         config = {
             'myservice': {'signature_version': 'foo'}
@@ -111,6 +112,35 @@ class TestAutoGeneratedClient(unittest.TestCase):
         request_signer.assert_called_with(
             mock.ANY, mock.ANY, mock.ANY, 'foo', mock.ANY, mock.ANY)
 
+    @mock.patch('botocore.client.RequestSigner')
+    def test_client_signature_override_arg(self, request_signer):
+        creator = self.create_client_creator()
+        config = botocore.client.Config(signature_version='foo')
+        service_client = creator.create_client(
+            'myservice', 'us-west-2', credentials=self.credentials,
+            client_config=config)
+        request_signer.assert_called_with(
+            mock.ANY, mock.ANY, mock.ANY, 'foo', mock.ANY, mock.ANY)
+
+    def test_anonymous_client_request(self):
+        creator = self.create_client_creator()
+        config = botocore.client.Config(signature_version=botocore.UNSIGNED)
+        service_client = creator.create_client(
+            'myservice', 'us-west-2', client_config=config)
+
+        response = service_client.test_operation(Foo='one')
+
+        # Make sure a request has been attempted
+        self.assertTrue(self.endpoint.make_request.called)
+
+        # Make sure the request parameters do NOT include auth
+        # information. The service defined above for these tests
+        # uses sigv4 by default (which we disable).
+        params = dict((k.lower(), v) for k, v in \
+            self.endpoint.make_request.call_args[0][1].items())
+        self.assertNotIn('authorization', params)
+        self.assertNotIn('x-amz-signature', params)
+
     def test_client_registers_request_created_handler(self):
         event_emitter = mock.Mock()
         creator = self.create_client_creator(event_emitter=event_emitter)
diff --git a/tests/unit/test_handlers.py b/tests/unit/test_handlers.py
index 7c8bc0813..e88ee08f7 100644
--- a/tests/unit/test_handlers.py
+++ b/tests/unit/test_handlers.py
@@ -17,6 +17,7 @@ import base64
 import mock
 import copy
 
+import botocore
 import botocore.session
 from botocore.hooks import first_non_none_response
 from botocore.awsrequest import AWSRequest
@@ -48,7 +49,7 @@ class TestHandlers(BaseSessionTest):
         self.assertEqual(converted_value, value)
 
     def test_disable_signing(self):
-        self.assertEqual(handlers.disable_signing(), '')
+        self.assertEqual(handlers.disable_signing(), botocore.UNSIGNED)
 
     def test_quote_source_header(self):
         for op in ('UploadPartCopy', 'CopyObject'):
diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py
index 95eb0954e..2cb4298c7 100644
--- a/tests/unit/test_session.py
+++ b/tests/unit/test_session.py
@@ -367,6 +367,15 @@ class TestCreateClient(BaseSessionTest):
                          "explicit credentials were provided to the "
                          "create_client call.")
 
+    @mock.patch('botocore.client.ClientCreator')
+    def test_config_passed_to_client_creator(self, client_creator):
+        config = client.Config()
+        self.session.create_client('sts', config=config)
+
+        client_creator.return_value.create_client.assert_called_with(
+            mock.ANY, mock.ANY, mock.ANY, mock.ANY, mock.ANY, mock.ANY,
+            scoped_config=mock.ANY, client_config=config)
+
 class TestPerformOperation(BaseSessionTest):
     def test_s3(self):
         service = self.session.get_service('s3')
diff --git a/tests/unit/test_signers.py b/tests/unit/test_signers.py
index a9a95f9f1..a0af4b12c 100644
--- a/tests/unit/test_signers.py
+++ b/tests/unit/test_signers.py
@@ -13,6 +13,7 @@
 
 import mock
 
+import botocore
 import botocore.auth
 
 from botocore.credentials import Credentials
@@ -115,10 +116,11 @@ class TestSigner(unittest.TestCase):
             request_signer=self.signer)
 
     def test_disable_signing(self):
-        # Returning a blank string from choose-signer disabled signing!
+        # Returning botocore.UNSIGNED from choose-signer disables signing!
         request = mock.Mock()
         auth = mock.Mock()
-        self.emitter.emit_until_response.return_value = (None, '')
+        self.emitter.emit_until_response.return_value = (None,
+                                                         botocore.UNSIGNED)
 
         with mock.patch.dict(botocore.auth.AUTH_TYPE_MAPS,
                              {'v4': auth}):