Modify oauth2client.multistore_file to store and retrieve credentials using an arbitrary key.
Reviewed in https://codereview.appspot.com/7067054/.
diff --git a/apiclient/http.py b/apiclient/http.py
index 28dba64..bfb7f77 100644
--- a/apiclient/http.py
+++ b/apiclient/http.py
@@ -1194,7 +1194,7 @@
Args:
http: httplib2.Http, an http object to be used in place of the one the
- HttpRequest request object was constructed with. If one isn't supplied
+ HttpRequest request object was constructed with. If one isn't supplied
then use a http object from the requests in this batch.
Returns:
diff --git a/oauth2client/client.py b/oauth2client/client.py
index 82db057..9ea30b7 100644
--- a/oauth2client/client.py
+++ b/oauth2client/client.py
@@ -234,7 +234,7 @@
class Storage(object):
"""Base class for all Storage objects.
- Store and retrieve a single credential. This class supports locking
+ Store and retrieve a single credential. This class supports locking
such that multiple processes and threads can operate on a single
store.
"""
@@ -540,7 +540,7 @@
Args:
store: Storage, an implementation of Stroage object.
This is needed to store the latest access_token if it
- has expired and been refreshed. This implementation uses
+ has expired and been refreshed. This implementation uses
locking to check for updates before updating the
access_token.
"""
@@ -661,7 +661,7 @@
Credentials can be applied to an httplib2.Http object using the
authorize() method, which then signs each request from that object
- with the OAuth 2.0 access token. This set of credentials is for the
+ with the OAuth 2.0 access token. This set of credentials is for the
use case where you have acquired an OAuth 2.0 access_token from
another place such as a JavaScript client or another web
application, and wish to use it from Python. Because only the
@@ -723,7 +723,7 @@
This credential does not require a flow to instantiate because it
represents a two legged flow, and therefore has all of the required
- information to generate and refresh its own access tokens. It must
+ information to generate and refresh its own access tokens. It must
be subclassed to generate the appropriate assertion string.
AssertionCredentials objects may be safely pickled and unpickled.
diff --git a/oauth2client/locked_file.py b/oauth2client/locked_file.py
index 1cfe532..313d0c5 100644
--- a/oauth2client/locked_file.py
+++ b/oauth2client/locked_file.py
@@ -64,7 +64,7 @@
return self._locked
def file_handle(self):
- """The file handle to the file. Valid only after opened."""
+ """The file handle to the file. Valid only after opened."""
return self._fh
def filename(self):
@@ -198,7 +198,7 @@
raise e
if e.errno != errno.EACCES:
raise e
- # We could not acquire the lock. Try again.
+ # We could not acquire the lock. Try again.
if (time.time() - start_time) >= timeout:
logger.warn('Could not lock %s in %s seconds' % (
self._filename, timeout))
@@ -280,7 +280,7 @@
if e[0] != _Win32Opener.FILE_IN_USE_ERROR:
raise
- # We could not acquire the lock. Try again.
+ # We could not acquire the lock. Try again.
if (time.time() - start_time) >= timeout:
logger.warn('Could not lock %s in %s seconds' % (
self._filename, timeout))
diff --git a/oauth2client/multistore_file.py b/oauth2client/multistore_file.py
index b8e9ff0..e1b39f7 100644
--- a/oauth2client/multistore_file.py
+++ b/oauth2client/multistore_file.py
@@ -3,7 +3,7 @@
"""Multi-credential file store with lock support.
This module implements a JSON credential store where multiple
-credentials can be stored in one file. That file supports locking
+credentials can be stored in one file. That file supports locking
both in a single process and across processes.
The credential themselves are keyed off of:
@@ -76,6 +76,55 @@
An object derived from client.Storage for getting/setting the
credential.
"""
+ # Recreate the legacy key with these specific parameters
+ key = {'clientId': client_id, 'userAgent': user_agent,
+ 'scope': util.scopes_to_string(scope)}
+ return get_credential_storage_custom_key(
+ filename, key, warn_on_readonly=warn_on_readonly)
+
+
+@util.positional(2)
+def get_credential_storage_custom_string_key(
+ filename, key_string, warn_on_readonly=True):
+ """Get a Storage instance for a credential using a single string as a key.
+
+ Allows you to provide a string as a custom key that will be used for
+ credential storage and retrieval.
+
+ Args:
+ filename: The JSON file storing a set of credentials
+ key_string: A string to use as the key for storing this credential.
+ warn_on_readonly: if True, log a warning if the store is readonly
+
+ Returns:
+ An object derived from client.Storage for getting/setting the
+ credential.
+ """
+ # Create a key dictionary that can be used
+ key_dict = {'key': key_string}
+ return get_credential_storage_custom_key(
+ filename, key_dict, warn_on_readonly=warn_on_readonly)
+
+
+@util.positional(2)
+def get_credential_storage_custom_key(
+ filename, key_dict, warn_on_readonly=True):
+ """Get a Storage instance for a credential using a dictionary as a key.
+
+ Allows you to provide a dictionary as a custom key that will be used for
+ credential storage and retrieval.
+
+ Args:
+ filename: The JSON file storing a set of credentials
+ key_dict: A dictionary to use as the key for storing this credential. There
+ is no ordering of the keys in the dictionary. Logically equivalent
+ dictionaries will produce equivalent storage keys.
+ warn_on_readonly: if True, log a warning if the store is readonly
+
+ Returns:
+ An object derived from client.Storage for getting/setting the
+ credential.
+ """
filename = os.path.expanduser(filename)
_multistores_lock.acquire()
try:
@@ -83,8 +132,8 @@
filename, _MultiStore(filename, warn_on_readonly=warn_on_readonly))
finally:
_multistores_lock.release()
- scope = util.scopes_to_string(scope)
- return multistore._get_storage(client_id, user_agent, scope)
+ key = util.dict_to_tuple_key(key_dict)
+ return multistore._get_storage(key)
class _MultiStore(object):
@@ -103,11 +152,11 @@
self._create_file_if_needed()
- # Cache of deserialized store. This is only valid after the
- # _MultiStore is locked or _refresh_data_cache is called. This is
+ # Cache of deserialized store. This is only valid after the
+ # _MultiStore is locked or _refresh_data_cache is called. This is
# of the form of:
#
- # (client_id, user_agent, scope) -> OAuth2Credential
+ # ((key, value), (key, value)...) -> OAuth2Credential
#
# If this is None, then the store hasn't been read yet.
self._data = None
@@ -115,11 +164,9 @@
class _Storage(BaseStorage):
"""A Storage object that knows how to read/write a single credential."""
- def __init__(self, multistore, client_id, user_agent, scope):
+ def __init__(self, multistore, key):
self._multistore = multistore
- self._client_id = client_id
- self._user_agent = user_agent
- self._scope = scope
+ self._key = key
def acquire_lock(self):
"""Acquires any lock necessary to access this Storage.
@@ -144,8 +191,7 @@
Returns:
oauth2client.client.Credentials
"""
- credential = self._multistore._get_credential(
- self._client_id, self._user_agent, self._scope)
+ credential = self._multistore._get_credential(self._key)
if credential:
credential.set_store(self)
return credential
@@ -158,7 +204,7 @@
Args:
credentials: Credentials, the credentials to store.
"""
- self._multistore._update_credential(credentials, self._scope)
+ self._multistore._update_credential(self._key, credentials)
def locked_delete(self):
"""Delete a credential.
@@ -168,8 +214,7 @@
Args:
credentials: Credentials, the credentials to store.
"""
- self._multistore._delete_credential(self._client_id, self._user_agent,
- self._scope)
+ self._multistore._delete_credential(self._key)
def _create_file_if_needed(self):
"""Create an empty file if necessary.
@@ -201,9 +246,9 @@
self._write()
elif not self._read_only or self._data is None:
# Only refresh the data if we are read/write or we haven't
- # cached the data yet. If we are readonly, we assume is isn't
+ # cached the data yet. If we are readonly, we assume is isn't
# changing out from under us and that we only have to read it
- # once. This prevents us from whacking any new access keys that
+ # once. This prevents us from whacking any new access keys that
# we have cached in memory but were unable to write out.
self._refresh_data_cache()
@@ -292,10 +337,7 @@
OAuth2Credential object.
"""
raw_key = cred_entry['key']
- client_id = raw_key['clientId']
- user_agent = raw_key['userAgent']
- scope = raw_key['scope']
- key = (client_id, user_agent, scope)
+ key = util.dict_to_tuple_key(raw_key)
credential = None
credential = Credentials.new_from_json(simplejson.dumps(cred_entry['credential']))
return (key, credential)
@@ -309,73 +351,59 @@
raw_creds = []
raw_data['data'] = raw_creds
for (cred_key, cred) in self._data.items():
- raw_key = {
- 'clientId': cred_key[0],
- 'userAgent': cred_key[1],
- 'scope': cred_key[2]
- }
+ raw_key = dict(cred_key)
raw_cred = simplejson.loads(cred.to_json())
raw_creds.append({'key': raw_key, 'credential': raw_cred})
self._locked_json_write(raw_data)
- def _get_credential(self, client_id, user_agent, scope):
+ def _get_credential(self, key):
"""Get a credential from the multistore.
The multistore must be locked.
Args:
- client_id: The client_id for the credential
- user_agent: The user agent for the credential
- scope: A string for the scope(s) being requested
+ key: The key used to retrieve the credential
Returns:
The credential specified or None if not present
"""
- key = (client_id, user_agent, scope)
-
return self._data.get(key, None)
- def _update_credential(self, cred, scope):
+ def _update_credential(self, key, cred):
"""Update a credential and write the multistore.
This must be called when the multistore is locked.
Args:
+ key: The key used to retrieve the credential
cred: The OAuth2Credential to update/set
- scope: The scope(s) that this credential covers
"""
- key = (cred.client_id, cred.user_agent, scope)
self._data[key] = cred
self._write()
- def _delete_credential(self, client_id, user_agent, scope):
+ def _delete_credential(self, key):
"""Delete a credential and write the multistore.
This must be called when the multistore is locked.
Args:
- client_id: The client_id for the credential
- user_agent: The user agent for the credential
- scope: The scope(s) that this credential covers
+ key: The key used to retrieve the credential
"""
- key = (client_id, user_agent, scope)
try:
del self._data[key]
except KeyError:
pass
self._write()
- def _get_storage(self, client_id, user_agent, scope):
+ def _get_storage(self, key):
"""Get a Storage object to get/set a credential.
This Storage is a 'view' into the multistore.
Args:
- client_id: The client_id for the credential
- user_agent: The user agent for the credential
- scope: A string for the scope(s) being requested
+ key: The key used to retrieve the credential
Returns:
A Storage object that can be used to get/set this cred
"""
- return self._Storage(self, client_id, user_agent, scope)
+ return self._Storage(self, key)
diff --git a/oauth2client/util.py b/oauth2client/util.py
index 8166d39..178d218 100644
--- a/oauth2client/util.py
+++ b/oauth2client/util.py
@@ -42,7 +42,7 @@
"""A decorator to declare that only the first N arguments my be positional.
This decorator makes it easy to support Python 3 style key-word only
- parameters. For example, in Python 3 it is possible to write:
+ parameters. For example, in Python 3 it is possible to write:
def fn(pos1, *, kwonly1=None, kwonly1=None):
...
@@ -92,7 +92,7 @@
respectively, if a declaration is violated.
Args:
- max_positional_arguments: Maximum number of positional arguments. All
+ max_positional_arguments: Maximum number of positional arguments. All
parameters after the this index must be keyword only.
Returns:
@@ -145,3 +145,18 @@
return scopes
else:
return ' '.join(scopes)
+
+
+def dict_to_tuple_key(dictionary):
+ """Converts a dictionary to a tuple that can be used as an immutable key.
+
+ The resulting key is always sorted so that logically equivalent dictionaries
+ always produce an identical tuple for a key.
+
+ Args:
+ dictionary: the dictionary to use as the key.
+
+ Returns:
+ A tuple representing the dictionary in it's naturally sorted ordering.
+ """
+ return tuple(sorted(dictionary.items()))
diff --git a/tests/test_oauth2client_file.py b/tests/test_oauth2client_file.py
index 7c65677..07e7608 100644
--- a/tests/test_oauth2client_file.py
+++ b/tests/test_oauth2client_file.py
@@ -35,6 +35,7 @@
from oauth2client import file
from oauth2client import locked_file
from oauth2client import multistore_file
+from oauth2client import util
from oauth2client.anyjson import simplejson
from oauth2client.client import AccessTokenCredentials
from oauth2client.client import AssertionCredentials
@@ -58,6 +59,21 @@
except OSError:
pass
+ def create_test_credentials(self):
+ access_token = 'foo'
+ client_secret = 'cOuDdkfjxxnv+'
+ refresh_token = '1/0/a.df219fjls0'
+ token_expiry = datetime.datetime.utcnow()
+ token_uri = 'https://www.google.com/accounts/o8/oauth2/token'
+ user_agent = 'refresh_checker/1.0'
+ client_id = 'some_client_id'
+
+ credentials = OAuth2Credentials(
+ access_token, client_id, client_secret,
+ refresh_token, token_expiry, token_uri,
+ user_agent)
+ return credentials
+
def test_non_existent_file_storage(self):
s = file.Storage(FILENAME)
credentials = s.get()
@@ -78,18 +94,7 @@
def test_pickle_and_json_interop(self):
# Write a file with a pickled OAuth2Credentials.
- access_token = 'foo'
- client_id = 'some_client_id'
- client_secret = 'cOuDdkfjxxnv+'
- refresh_token = '1/0/a.df219fjls0'
- token_expiry = datetime.datetime.utcnow()
- token_uri = 'https://www.google.com/accounts/o8/oauth2/token'
- user_agent = 'refresh_checker/1.0'
-
- credentials = OAuth2Credentials(
- access_token, client_id, client_secret,
- refresh_token, token_expiry, token_uri,
- user_agent)
+ credentials = self.create_test_credentials()
f = open(FILENAME, 'w')
pickle.dump(credentials, f)
@@ -112,18 +117,7 @@
self.assertEquals(data['_module'], OAuth2Credentials.__module__)
def test_token_refresh(self):
- access_token = 'foo'
- client_id = 'some_client_id'
- client_secret = 'cOuDdkfjxxnv+'
- refresh_token = '1/0/a.df219fjls0'
- token_expiry = datetime.datetime.utcnow()
- token_uri = 'https://www.google.com/accounts/o8/oauth2/token'
- user_agent = 'refresh_checker/1.0'
-
- credentials = OAuth2Credentials(
- access_token, client_id, client_secret,
- refresh_token, token_expiry, token_uri,
- user_agent)
+ credentials = self.create_test_credentials()
s = file.Storage(FILENAME)
s.put(credentials)
@@ -136,18 +130,7 @@
self.assertEquals(credentials.access_token, 'bar')
def test_credentials_delete(self):
- access_token = 'foo'
- client_id = 'some_client_id'
- client_secret = 'cOuDdkfjxxnv+'
- refresh_token = '1/0/a.df219fjls0'
- token_expiry = datetime.datetime.utcnow()
- token_uri = 'https://www.google.com/accounts/o8/oauth2/token'
- user_agent = 'refresh_checker/1.0'
-
- credentials = OAuth2Credentials(
- access_token, client_id, client_secret,
- refresh_token, token_expiry, token_uri,
- user_agent)
+ credentials = self.create_test_credentials()
s = file.Storage(FILENAME)
s.put(credentials)
@@ -175,18 +158,7 @@
self.assertEquals('0600', oct(stat.S_IMODE(os.stat(FILENAME).st_mode)))
def test_read_only_file_fail_lock(self):
- access_token = 'foo'
- client_secret = 'cOuDdkfjxxnv+'
- refresh_token = '1/0/a.df219fjls0'
- token_expiry = datetime.datetime.utcnow()
- token_uri = 'https://www.google.com/accounts/o8/oauth2/token'
- user_agent = 'refresh_checker/1.0'
- client_id = 'some_client_id'
-
- credentials = OAuth2Credentials(
- access_token, client_id, client_secret,
- refresh_token, token_expiry, token_uri,
- user_agent)
+ credentials = self.create_test_credentials()
open(FILENAME, 'a+b').close()
os.chmod(FILENAME, 0400)
@@ -230,18 +202,7 @@
self.assertEquals(None, credentials)
def test_multistore_file(self):
- access_token = 'foo'
- client_secret = 'cOuDdkfjxxnv+'
- refresh_token = '1/0/a.df219fjls0'
- token_expiry = datetime.datetime.utcnow()
- token_uri = 'https://www.google.com/accounts/o8/oauth2/token'
- user_agent = 'refresh_checker/1.0'
- client_id = 'some_client_id'
-
- credentials = OAuth2Credentials(
- access_token, client_id, client_secret,
- refresh_token, token_expiry, token_uri,
- user_agent)
+ credentials = self.create_test_credentials()
store = multistore_file.get_credential_storage(
FILENAME,
@@ -263,5 +224,65 @@
if os.name == 'posix':
self.assertEquals('0600', oct(stat.S_IMODE(os.stat(FILENAME).st_mode)))
+ def test_multistore_file_custom_key(self):
+ credentials = self.create_test_credentials()
+
+ custom_key = {'myapp': 'testing', 'clientid': 'some client'}
+ store = multistore_file.get_credential_storage_custom_key(
+ FILENAME, custom_key)
+
+ store.put(credentials)
+ stored_credentials = store.get()
+
+ self.assertNotEquals(None, stored_credentials)
+ self.assertEqual(credentials.access_token, stored_credentials.access_token)
+
+ store.delete()
+ stored_credentials = store.get()
+
+ self.assertEquals(None, stored_credentials)
+
+ def test_multistore_file_custom_string_key(self):
+ credentials = self.create_test_credentials()
+
+ # store with string key
+ store = multistore_file.get_credential_storage_custom_string_key(
+ FILENAME, 'mykey')
+
+ store.put(credentials)
+ stored_credentials = store.get()
+
+ self.assertNotEquals(None, stored_credentials)
+ self.assertEqual(credentials.access_token, stored_credentials.access_token)
+
+ # try retrieving with a dictionary
+ store_dict = multistore_file.get_credential_storage_custom_string_key(
+ FILENAME, {'key': 'mykey'})
+ stored_credentials = store.get()
+ self.assertNotEquals(None, stored_credentials)
+ self.assertEqual(credentials.access_token, stored_credentials.access_token)
+
+ store.delete()
+ stored_credentials = store.get()
+
+ self.assertEquals(None, stored_credentials)
+
+ def test_multistore_file_backwards_compatibility(self):
+ credentials = self.create_test_credentials()
+ scopes = ['scope1', 'scope2']
+
+ # store the credentials using the legacy key method
+ store = multistore_file.get_credential_storage(
+ FILENAME, 'client_id', 'user_agent', scopes)
+ store.put(credentials)
+
+ # retrieve the credentials using a custom key that matches the legacy key
+ key = {'clientId': 'client_id', 'userAgent': 'user_agent',
+ 'scope': util.scopes_to_string(scopes)}
+ store = multistore_file.get_credential_storage_custom_key(FILENAME, key)
+ stored_credentials = store.get()
+
+ self.assertEqual(credentials.access_token, stored_credentials.access_token)
+
if __name__ == '__main__':
unittest.main()
diff --git a/tests/test_oauth2client_util.py b/tests/test_oauth2client_util.py
index 6c72521..2d67316 100644
--- a/tests/test_oauth2client_util.py
+++ b/tests/test_oauth2client_util.py
@@ -25,3 +25,20 @@
]
for expected, case in cases:
self.assertEqual(expected, util.scopes_to_string(case))
+
+
+class KeyConversionTests(unittest.TestCase):
+
+ def test_key_conversions(self):
+ d = {'somekey': 'some value', 'another': 'something else', 'onemore': 'foo'}
+ tuple_key = util.dict_to_tuple_key(d)
+
+ # the resulting key should be naturally sorted
+ self.assertEqual(
+ (('another', 'something else'),
+ ('onemore', 'foo'),
+ ('somekey', 'some value')),
+ tuple_key)
+
+ # check we get the original dictionary back
+ self.assertEqual(d, dict(tuple_key))