Merge pull request #429 from dhermes/move-dict-to-tuple

Moving util.dict_to_tuple_key() into only module that uses it.
diff --git a/docs/conf.py b/docs/conf.py
index 46fdd87..2b16ebc 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -8,6 +8,31 @@
 from pkg_resources import get_distribution
 import sys
 
+import mock
+
+# See
+# (http://read-the-docs.readthedocs.org/en/latest/faq.html#\
+#  i-get-import-errors-on-libraries-that-depend-on-c-modules)
+
+class Mock(mock.Mock):
+
+    @classmethod
+    def __getattr__(cls, name):
+            return Mock()
+
+
+MOCK_MODULES = (
+    'google',
+    'google.appengine',
+    'google.appengine.api',
+    'google.appengine.api.app_identiy',
+    'google.appengine.api.urlfetch',
+    'google.appengine.ext',
+    'google.appengine.ext.webapp',
+    'google.appengine.ext.webapp.util',
+)
+
+
 # If extensions (or modules to document with autodoc) are in another directory,
 # add these directories to sys.path here. If the directory is relative to the
 # documentation root, use os.path.abspath to make it absolute, like shown here.
@@ -40,32 +65,18 @@
 # settings module and load it. This assumes django has been installed
 # (but it must be for the docs to build), so if it has not already
 # been installed run `pip install -r docs/requirements.txt`.
+os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.contrib.test_django_settings'
 import django
 if django.VERSION[1] < 7:
     sys.path.insert(0, '.')
-    os.environ['DJANGO_SETTINGS_MODULE'] = 'django_settings'
 
 # -- Options for HTML output ----------------------------------------------
 
+# We fake our more expensive imports when building the docs.
+sys.modules.update((mod_name, Mock()) for mod_name in MOCK_MODULES)
+
 # We want to set the RTD theme, but not if we're on RTD.
-if os.environ.get('READTHEDOCS', None) == 'True':
-    # Download the GAE SDK if we are building on READTHEDOCS.
-    docs_dir = os.path.dirname(os.path.abspath(__file__))
-    root_dir = os.path.abspath(os.path.join(docs_dir, '..'))
-    gae_dir = os.path.join(root_dir, 'google_appengine')
-    if not os.path.isdir(gae_dir):
-        scripts_dir = os.path.join(root_dir, 'scripts')
-        sys.path.append(scripts_dir)
-        import fetch_gae_sdk
-        # The first argument is the script name and the second is
-        # the destination dir (where google_appengine is downloaded).
-        result = fetch_gae_sdk.main([None, root_dir])
-        if result not in (0, None):
-            sys.stderr.write('Result failed %d\n' % (result,))
-            sys.exit(result)
-        # Allow imports from the GAE directory as well.
-        sys.path.append(gae_dir)
-else:
+if os.environ.get('READTHEDOCS', None) != 'True':
     import sphinx_rtd_theme
     html_theme = 'sphinx_rtd_theme'
     html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
diff --git a/docs/django_settings.py b/docs/django_settings.py
deleted file mode 100644
index dd524da..0000000
--- a/docs/django_settings.py
+++ /dev/null
@@ -1 +0,0 @@
-SECRET_KEY = 'abcdefg'
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 7b9458e..433a833 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,6 +1,7 @@
 django
 flask
 keyring
+mock
 pycrypto>=2.6
 pyopenssl>=0.14
 python-gflags
diff --git a/oauth2client/client.py b/oauth2client/client.py
index fd0d699..a388fb8 100644
--- a/oauth2client/client.py
+++ b/oauth2client/client.py
@@ -1617,6 +1617,18 @@
         """
         self._do_revoke(http_request, self.access_token)
 
+    def sign_blob(self, blob):
+        """Cryptographically sign a blob (of bytes).
+
+        Args:
+            blob: bytes, Message to be signed.
+
+        Returns:
+            tuple, A pair of the private key ID used to sign the blob and
+            the signed contents.
+        """
+        raise NotImplementedError('This method is abstract.')
+
 
 def _RequireCryptoOrDie():
     """Ensure we have a crypto library, or throw CryptoUnavailableError.
diff --git a/oauth2client/contrib/appengine.py b/oauth2client/contrib/appengine.py
index a56cb74..2f05254 100644
--- a/oauth2client/contrib/appengine.py
+++ b/oauth2client/contrib/appengine.py
@@ -166,6 +166,7 @@
         self.scope = util.scopes_to_string(scope)
         self._kwargs = kwargs
         self.service_account_id = kwargs.get('service_account_id', None)
+        self._service_account_email = None
 
         # Assertion type is no longer used, but still in the
         # parent class signature.
@@ -210,6 +211,34 @@
     def create_scoped(self, scopes):
         return AppAssertionCredentials(scopes, **self._kwargs)
 
+    def sign_blob(self, blob):
+        """Cryptographically sign a blob (of bytes).
+
+        Implements abstract method
+        :meth:`oauth2client.client.AssertionCredentials.sign_blob`.
+
+        Args:
+            blob: bytes, Message to be signed.
+
+        Returns:
+            tuple, A pair of the private key ID used to sign the blob and
+            the signed contents.
+        """
+        return app_identity.sign_blob(blob)
+
+    @property
+    def service_account_email(self):
+        """Get the email for the current service account.
+
+        Returns:
+            string, The email associated with the Google App Engine
+            service account.
+        """
+        if self._service_account_email is None:
+            self._service_account_email = (
+                app_identity.get_service_account_name())
+        return self._service_account_email
+
 
 class FlowProperty(db.Property):
     """App Engine datastore Property for Flow.
diff --git a/oauth2client/contrib/gce.py b/oauth2client/contrib/gce.py
index 53a7b1c..6542008 100644
--- a/oauth2client/contrib/gce.py
+++ b/oauth2client/contrib/gce.py
@@ -21,6 +21,7 @@
 import logging
 import warnings
 
+import httplib2
 from six.moves import http_client
 from six.moves import urllib
 
@@ -35,8 +36,10 @@
 logger = logging.getLogger(__name__)
 
 # URI Template for the endpoint that returns access_tokens.
-META = ('http://metadata.google.internal/computeMetadata/v1/instance/'
-        'service-accounts/default/token')
+_METADATA_ROOT = ('http://metadata.google.internal/computeMetadata/v1/'
+                  'instance/service-accounts/default/')
+META = _METADATA_ROOT + 'token'
+_DEFAULT_EMAIL_METADATA = _METADATA_ROOT + 'email'
 _SCOPES_WARNING = """\
 You have requested explicit scopes to be used with a GCE service account.
 Using this argument will have no effect on the actual scopes for tokens
@@ -45,6 +48,30 @@
 """
 
 
+def _get_service_account_email(http_request=None):
+    """Get the GCE service account email from the current environment.
+
+    Args:
+        http_request: callable, (Optional) a callable that matches the method
+                      signature of httplib2.Http.request, used to make
+                      the request to the metadata service.
+
+    Returns:
+        tuple, A pair where the first entry is an optional response (from a
+        failed request) and the second is service account email found (as
+        a string).
+    """
+    if http_request is None:
+        http_request = httplib2.Http().request
+    response, content = http_request(
+        _DEFAULT_EMAIL_METADATA, headers={'Metadata-Flavor': 'Google'})
+    if response.status == http_client.OK:
+        content = _from_bytes(content)
+        return None, content
+    else:
+        return response, content
+
+
 class AppAssertionCredentials(AssertionCredentials):
     """Credentials object for Compute Engine Assertion Grants
 
@@ -78,6 +105,7 @@
         # Assertion type is no longer used, but still in the
         # parent class signature.
         super(AppAssertionCredentials, self).__init__(None)
+        self._service_account_email = None
 
     @classmethod
     def from_json(cls, json_data):
@@ -123,3 +151,44 @@
 
     def create_scoped(self, scopes):
         return AppAssertionCredentials(scopes, **self.kwargs)
+
+    def sign_blob(self, blob):
+        """Cryptographically sign a blob (of bytes).
+
+        This method is provided to support a common interface, but
+        the actual key used for a Google Compute Engine service account
+        is not available, so it can't be used to sign content.
+
+        Args:
+            blob: bytes, Message to be signed.
+
+        Raises:
+            NotImplementedError, always.
+        """
+        raise NotImplementedError(
+            'Compute Engine service accounts cannot sign blobs')
+
+    @property
+    def service_account_email(self):
+        """Get the email for the current service account.
+
+        Uses the Google Compute Engine metadata service to retrieve the email
+        of the default service account.
+
+        Returns:
+            string, The email associated with the Google Compute Engine
+            service account.
+
+        Raises:
+            AttributeError, if the email can not be retrieved from the Google
+            Compute Engine metadata service.
+        """
+        if self._service_account_email is None:
+            failure, email = _get_service_account_email()
+            if failure is None:
+                self._service_account_email = email
+            else:
+                raise AttributeError('Failed to retrieve the email from the '
+                                     'Google Compute Engine metadata service',
+                                     failure, email)
+        return self._service_account_email
diff --git a/oauth2client/service_account.py b/oauth2client/service_account.py
index b4d1dc8..f009b0c 100644
--- a/oauth2client/service_account.py
+++ b/oauth2client/service_account.py
@@ -320,10 +320,27 @@
                                      key_id=self._private_key_id)
 
     def sign_blob(self, blob):
+        """Cryptographically sign a blob (of bytes).
+
+        Implements abstract method
+        :meth:`oauth2client.client.AssertionCredentials.sign_blob`.
+
+        Args:
+            blob: bytes, Message to be signed.
+
+        Returns:
+            tuple, A pair of the private key ID used to sign the blob and
+            the signed contents.
+        """
         return self._private_key_id, self._signer.sign(blob)
 
     @property
     def service_account_email(self):
+        """Get the email for the current service account.
+
+        Returns:
+            string, The email associated with the service account.
+        """
         return self._service_account_email
 
     @property
diff --git a/scripts/build_docs.sh b/scripts/build_docs.sh
index b583416..170b113 100755
--- a/scripts/build_docs.sh
+++ b/scripts/build_docs.sh
@@ -14,15 +14,10 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 #
-# Build the oauth2client docs, installing the GAE SDK as needed.
+# Build the oauth2client docs.
 
 set -e
 
-if [[ -z "${SKIP_GAE_SDK}" ]]; then
-  scripts/fetch_gae_sdk.py
-  export PYTHONPATH="${PWD}/google_appengine:${PYTHONPATH}"
-fi
-
 rm -rf docs/_build/* docs/source/*
 sphinx-apidoc --separate --force -o docs/source oauth2client
 # We only have one package, so modules.rst is overkill.
diff --git a/tests/contrib/test_appengine.py b/tests/contrib/test_appengine.py
index 4e82429..438548b 100644
--- a/tests/contrib/test_appengine.py
+++ b/tests/contrib/test_appengine.py
@@ -116,14 +116,29 @@
 
     class AppIdentityStubImpl(apiproxy_stub.APIProxyStub):
 
-        def __init__(self):
+        def __init__(self, key_name=None, sig_bytes=None,
+                     svc_acct=None):
             super(TestAppAssertionCredentials.AppIdentityStubImpl,
                   self).__init__('app_identity_service')
+            self._key_name = key_name
+            self._sig_bytes = sig_bytes
+            self._sign_calls = []
+            self._svc_acct = svc_acct
+            self._get_acct_name_calls = 0
 
         def _Dynamic_GetAccessToken(self, request, response):
             response.set_access_token('a_token_123')
             response.set_expiration_time(time.time() + 1800)
 
+        def _Dynamic_SignForApp(self, request, response):
+            response.set_key_name(self._key_name)
+            response.set_signature_bytes(self._sig_bytes)
+            self._sign_calls.append(request.bytes_to_sign())
+
+        def _Dynamic_GetServiceAccountName(self, request, response):
+            response.set_service_account_name(self._svc_acct)
+            self._get_acct_name_calls += 1
+
     class ErroringAppIdentityStubImpl(apiproxy_stub.APIProxyStub):
 
         def __init__(self):
@@ -210,6 +225,49 @@
         self.assertTrue(isinstance(new_credentials, AppAssertionCredentials))
         self.assertEqual('dummy_scope', new_credentials.scope)
 
+    def test_sign_blob(self):
+        key_name = b'1234567890'
+        sig_bytes = b'himom'
+        app_identity_stub = self.AppIdentityStubImpl(
+            key_name=key_name, sig_bytes=sig_bytes)
+        apiproxy_stub_map.apiproxy = apiproxy_stub_map.APIProxyStubMap()
+        apiproxy_stub_map.apiproxy.RegisterStub('app_identity_service',
+                                                app_identity_stub)
+        credentials = AppAssertionCredentials([])
+        to_sign = b'blob'
+        self.assertEqual(app_identity_stub._sign_calls, [])
+        result = credentials.sign_blob(to_sign)
+        self.assertEqual(result, (key_name, sig_bytes))
+        self.assertEqual(app_identity_stub._sign_calls, [to_sign])
+
+    def test_service_account_email(self):
+        acct_name = 'new-value@appspot.gserviceaccount.com'
+        app_identity_stub = self.AppIdentityStubImpl(svc_acct=acct_name)
+        apiproxy_stub_map.apiproxy = apiproxy_stub_map.APIProxyStubMap()
+        apiproxy_stub_map.apiproxy.RegisterStub('app_identity_service',
+                                                app_identity_stub)
+
+        credentials = AppAssertionCredentials([])
+        self.assertIsNone(credentials._service_account_email)
+        self.assertEqual(app_identity_stub._get_acct_name_calls, 0)
+        self.assertEqual(credentials.service_account_email, acct_name)
+        self.assertIsNotNone(credentials._service_account_email)
+        self.assertEqual(app_identity_stub._get_acct_name_calls, 1)
+
+    def test_service_account_email_already_set(self):
+        acct_name = 'existing@appspot.gserviceaccount.com'
+        credentials = AppAssertionCredentials([])
+        credentials._service_account_email = acct_name
+
+        app_identity_stub = self.AppIdentityStubImpl(svc_acct=acct_name)
+        apiproxy_stub_map.apiproxy = apiproxy_stub_map.APIProxyStubMap()
+        apiproxy_stub_map.apiproxy.RegisterStub('app_identity_service',
+                                                app_identity_stub)
+
+        self.assertEqual(app_identity_stub._get_acct_name_calls, 0)
+        self.assertEqual(credentials.service_account_email, acct_name)
+        self.assertEqual(app_identity_stub._get_acct_name_calls, 0)
+
     def test_get_access_token(self):
         app_identity_stub = self.AppIdentityStubImpl()
         apiproxy_stub_map.apiproxy = apiproxy_stub_map.APIProxyStubMap()
diff --git a/tests/contrib/test_gce.py b/tests/contrib/test_gce.py
index 3c8f33c..48da976 100644
--- a/tests/contrib/test_gce.py
+++ b/tests/contrib/test_gce.py
@@ -17,14 +17,17 @@
 import json
 from six.moves import http_client
 from six.moves import urllib
-import unittest
+import unittest2
 
 import mock
 
+import httplib2
 from oauth2client._helpers import _to_bytes
 from oauth2client.client import AccessTokenRefreshError
 from oauth2client.client import Credentials
 from oauth2client.client import save_to_well_known_file
+from oauth2client.contrib.gce import _DEFAULT_EMAIL_METADATA
+from oauth2client.contrib.gce import _get_service_account_email
 from oauth2client.contrib.gce import _SCOPES_WARNING
 from oauth2client.contrib.gce import AppAssertionCredentials
 
@@ -32,7 +35,7 @@
 __author__ = 'jcgregorio@google.com (Joe Gregorio)'
 
 
-class AppAssertionCredentialsTests(unittest.TestCase):
+class AppAssertionCredentialsTests(unittest2.TestCase):
 
     def test_constructor(self):
         credentials = AppAssertionCredentials(foo='bar')
@@ -150,6 +153,49 @@
         self.assertEqual('dummy_scope', new_credentials.scope)
         warn_mock.assert_called_once_with(_SCOPES_WARNING)
 
+    def test_sign_blob_not_implemented(self):
+        credentials = AppAssertionCredentials([])
+        with self.assertRaises(NotImplementedError):
+            credentials.sign_blob(b'blob')
+
+    @mock.patch('oauth2client.contrib.gce._get_service_account_email',
+                return_value=(None, 'retrieved@email.com'))
+    def test_service_account_email(self, get_email):
+        credentials = AppAssertionCredentials([])
+        self.assertIsNone(credentials._service_account_email)
+        self.assertEqual(credentials.service_account_email,
+                         get_email.return_value[1])
+        self.assertIsNotNone(credentials._service_account_email)
+        get_email.assert_called_once_with()
+
+    @mock.patch('oauth2client.contrib.gce._get_service_account_email')
+    def test_service_account_email_already_set(self, get_email):
+        credentials = AppAssertionCredentials([])
+        acct_name = 'existing@email.com'
+        credentials._service_account_email = acct_name
+        self.assertEqual(credentials.service_account_email, acct_name)
+        get_email.assert_not_called()
+
+    @mock.patch('oauth2client.contrib.gce._get_service_account_email')
+    def test_service_account_email_failure(self, get_email):
+        # Set-up the mock.
+        bad_response = httplib2.Response({'status': http_client.NOT_FOUND})
+        content = b'bad-bytes-nothing-here'
+        get_email.return_value = (bad_response, content)
+        # Test the failure.
+        credentials = AppAssertionCredentials([])
+        self.assertIsNone(credentials._service_account_email)
+        with self.assertRaises(AttributeError) as exc_manager:
+            getattr(credentials, 'service_account_email')
+
+        error_msg = ('Failed to retrieve the email from the '
+                     'Google Compute Engine metadata service')
+        self.assertEqual(
+            exc_manager.exception.args,
+            (error_msg, bad_response, content))
+        self.assertIsNone(credentials._service_account_email)
+        get_email.assert_called_once_with()
+
     def test_get_access_token(self):
         http = mock.MagicMock()
         http.request = mock.MagicMock(
@@ -178,5 +224,43 @@
             os.path.isdir = ORIGINAL_ISDIR
 
 
+class Test__get_service_account_email(unittest2.TestCase):
+
+    def test_success(self):
+        http_request = mock.MagicMock()
+        acct_name = b'1234567890@developer.gserviceaccount.com'
+        http_request.return_value = (
+            httplib2.Response({'status': http_client.OK}), acct_name)
+        result = _get_service_account_email(http_request)
+        self.assertEqual(result, (None, acct_name.decode('utf-8')))
+        http_request.assert_called_once_with(
+            _DEFAULT_EMAIL_METADATA,
+            headers={'Metadata-Flavor': 'Google'})
+
+    @mock.patch.object(httplib2.Http, 'request')
+    def test_success_default_http(self, http_request):
+        # Don't make _from_bytes() work too hard.
+        acct_name = u'1234567890@developer.gserviceaccount.com'
+        http_request.return_value = (
+            httplib2.Response({'status': http_client.OK}), acct_name)
+        result = _get_service_account_email()
+        self.assertEqual(result, (None, acct_name))
+        http_request.assert_called_once_with(
+            _DEFAULT_EMAIL_METADATA,
+            headers={'Metadata-Flavor': 'Google'})
+
+    def test_failure(self):
+        http_request = mock.MagicMock()
+        response = httplib2.Response({'status': http_client.NOT_FOUND})
+        content = b'Not found'
+        http_request.return_value = (response, content)
+        result = _get_service_account_email(http_request)
+
+        self.assertEqual(result, (response, content))
+        http_request.assert_called_once_with(
+            _DEFAULT_EMAIL_METADATA,
+            headers={'Metadata-Flavor': 'Google'})
+
+
 if __name__ == '__main__':  # pragma: NO COVER
-    unittest.main()
+    unittest2.main()
diff --git a/tests/test_client.py b/tests/test_client.py
index 018727c..03b30fb 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -1064,6 +1064,11 @@
             self, '400', revoke_raise=True,
             valid_bool_value=False, token_attr='access_token')
 
+    def test_sign_blob_abstract(self):
+        credentials = AssertionCredentials(None)
+        with self.assertRaises(NotImplementedError):
+            credentials.sign_blob(b'blob')
+
 
 class UpdateQueryParamsTest(unittest2.TestCase):
     def test_update_query_params_no_params(self):