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):