Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
src/*.pyc
src/momento/*.pyc
src/momento/__pycache__
tests/__pycache__

# Setuptools distribution folder.
/dist/
Expand Down
21 changes: 16 additions & 5 deletions src/momento/simple_cache_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@ def create_cache(self, cache_name):
CreateCacheResponse

Raises:
InvalidInputError: For any SDK checks that fail.
CacheValueError: If provided cache_name is rejected by the service
CacheExistsError: If cache with the given name already exists
InvalidInputError: If cache name is None.
ClientSdkError: For any SDK checks that fail.
CacheValueError: If provided cache_name is empty.
CacheExistsError: If cache with the given name already exists.
PermissionError: If the provided Momento Auth Token is invalid to perform the requested operation.
"""
return self._control_client.create_cache(cache_name)

Expand All @@ -47,7 +49,10 @@ def delete_cache(self, cache_name):

Raises:
CacheNotFoundError: If an attempt is made to delete a MomentoCache that doesn't exits.
InvalidInputError: For any SDK checks that fail.
InvalidInputError: If cache name is None.
ClientSdkError: For any SDK checks that fail.
CacheValueError: If provided cache name is empty
PermissionError: If the provided Momento Auth Token is invalid to perform the requested operation.
"""
return self._control_client.delete_cache(cache_name)

Expand All @@ -62,6 +67,7 @@ def list_caches(self, next_token=None):

Raises:
Exception to notify either sdk, grpc, or operation error.
PermissionError: If the provided Momento Auth Token is invalid to perform the requested operation.
"""
return self._control_client.list_caches(next_token)

Expand All @@ -72,13 +78,14 @@ def set(self, cache_name, key, value, ttl_seconds=None):
cache_name: Name of the cache to store the item in.
key (string or bytes): The key to be used to store item.
value (string or bytes): The value to be stored.
ttl_second (Optional): Time to live in cache in seconds. If not provided, then default TTL for the cache client instance is used.
ttl_seconds (Optional): Time to live in cache in seconds. If not provided, then default TTL for the cache client instance is used.

Returns:
CacheSetResponse

Raises:
InvalidInputError: If service validation fails for provided values.
ClientSdkError: If cache name is invalid type.
CacheNotFoundError: If an attempt is made to store an item in a cache that doesn't exist.
PermissionError: If the provided Momento Auth Token is invalid to perform the requested operation.
InternalServerError: If server encountered an unknown error while trying to store the item.
Expand All @@ -97,6 +104,7 @@ def get(self, cache_name, key):

Raises:
InvalidInputError: If service validation fails for provided values.
ClientSdkError: If cache name is invalid type.
CacheNotFoundError: If an attempt is made to retrieve an item in a cache that doesn't exist.
PermissionError: If the provided Momento Auth Token is invalid to perform the requested operation.
InternalServerError: If server encountered an unknown error while trying to retrieve the item.
Expand All @@ -112,5 +120,8 @@ def init(auth_token, item_default_ttl_seconds):
item_default_ttl_seconds: A default Time To Live in seconds for cache objects created by this client. It is possible to override this setting when calling the set method.
Returns:
SimpleCacheClient
Raises:
InvalidInputError: If service validation fails for provided values
InternalServerError: If server encountered an unknown error.
"""
return SimpleCacheClient(auth_token, item_default_ttl_seconds)
298 changes: 292 additions & 6 deletions tests/test_momento.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import unittest
import os
import uuid
import time

import momento.simple_cache_client as simple_cache_client
import momento.errors as errors
from momento.cache_operation_responses import CacheResult

_AUTH_TOKEN = os.getenv('TEST_AUTH_TOKEN')
_TEST_CACHE_NAME = os.getenv('TEST_CACHE_NAME')
_BAD_AUTH_TOKEN = "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJpbnRlZ3JhdGlvbiIsImNwIjoiY29udHJvbC5jZWxsLWFscGhhLWRldi5wcmVwcm9kLmEubW9tZW50b2hxLmNvbSIsImMiOiJjYWNoZS5jZWxsLWFscGhhLWRldi5wcmVwcm9kLmEubW9tZW50b2hxLmNvbSJ9.gdghdjjfjyehhdkkkskskmmls76573jnajhjjjhjdhnndy"
_DEFAULT_TTL_SECONDS = 60


Expand All @@ -22,18 +25,301 @@ def setUpClass(cls):
"Integration tests require TEST_CACHE_NAME env var; see README for more details."
)

def test_happy_path(self):
# default client for use in tests
cls.client = simple_cache_client.init(_AUTH_TOKEN, _DEFAULT_TTL_SECONDS)

# ensure test cache exists
try:
cls.client.create_cache(_TEST_CACHE_NAME)
except errors.CacheExistsError:
# do nothing, cache already exists
pass

@classmethod
def tearDownClass(cls):
# close client
cls.client._control_client.close()
cls.client._data_client.close()

# basic happy path test
def test_create_cache_get_set_values_and_delete_cache(self):
cache_name = str(uuid.uuid4())
key = str(uuid.uuid4())
value = str(uuid.uuid4())

self.client.create_cache(cache_name)

set_resp = self.client.set(cache_name, key, value)
self.assertEqual(set_resp.str_utf8(), value)

get_resp = self.client.get(cache_name, key)
self.assertEqual(get_resp.result(), CacheResult.HIT)
self.assertEqual(get_resp.str_utf8(), value)

get_for_key_in_some_other_cache = self.client.get(_TEST_CACHE_NAME, key)
self.assertEqual(get_for_key_in_some_other_cache.result(), CacheResult.MISS)

self.client.delete_cache(cache_name)

# init

def test_init_throws_exception_when_client_uses_negative_default_ttl(self):
with self.assertRaises(errors.InvalidInputError) as cm:
simple_cache_client.init(_AUTH_TOKEN, -1)
self.assertEqual('{}'.format(cm.exception), "TTL Seconds must be a non-negative integer")

def test_init_throws_exception_for_non_jwt_token(self):
with self.assertRaises(errors.InvalidInputError) as cm:
simple_cache_client.init("notanauthtoken", _DEFAULT_TTL_SECONDS)
self.assertEqual('{}'.format(cm.exception), "Invalid Auth token.")

# create_cache

def test_create_cache_throws_already_exists_when_creating_existing_cache(self):
with self.assertRaises(errors.CacheExistsError):
self.client.create_cache(_TEST_CACHE_NAME)

def test_create_cache_throws_exception_for_empty_cache_name(self):
with self.assertRaises(errors.CacheValueError):
self.client.create_cache("")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was surprised that this was CacheValueError for create_cache("") when in the next test below it's InvalidInputError for create_cache(None)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. This is pretty unfortunate. The custom 'None' check is needed to protect SDK from failing due to protobuf errors. The distinction between CacheValueError and InvalidInputError is that while the former is from the service while the later comes from the client.


def test_create_cache_throws_validation_exception_for_null_cache_name(self):
with self.assertRaises(errors.InvalidInputError) as cm:
self.client.create_cache(None)
self.assertEqual('{}'.format(cm.exception), "Cache Name cannot be None")

def test_create_cache_with_bad_cache_name_throws_exception(self):
with self.assertRaises(errors.ClientSdkError) as cm:
self.client.create_cache(1)
self.assertEqual('{}'.format(cm.exception),
"Operation failed with error: 1 has type int, but expected one of: bytes, unicode")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was surprised that create_cache(1) is a ClientSdkError rather than InvalidInputError.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type safety - it would be totally reasonable to add a bug that states the cache name check should ensure non- None string type.

For any unknown failures or conditions that SDK doesn't understand, we default to ClientSdkError.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened #24


def test_create_cache_throws_permission_exception_for_bad_token(self):
with simple_cache_client.init(_BAD_AUTH_TOKEN,
_DEFAULT_TTL_SECONDS) as simple_cache:
with self.assertRaises(errors.PermissionError):
simple_cache.create_cache(str(uuid.uuid4()))

# delete_cache
def test_delete_cache_succeeds(self):
cache_name = str(uuid.uuid4())

self.client.create_cache(cache_name)
with self.assertRaises(errors.CacheExistsError):
self.client.create_cache(cache_name)
self.client.delete_cache(cache_name)
with self.assertRaises(errors.CacheNotFoundError):
self.client.delete_cache(cache_name)

def test_delete_cache_throws_not_found_when_deleting_unknown_cache(self):
cache_name = str(uuid.uuid4())
with self.assertRaises(errors.CacheNotFoundError):
self.client.delete_cache(cache_name)

def test_delete_cache_throws_invalid_input_for_null_cache_name(self):
with self.assertRaises(errors.InvalidInputError):
self.client.delete_cache(None)

def test_delete_cache_throws_exception_for_empty_cache_name(self):
with self.assertRaises(errors.CacheValueError):
self.client.delete_cache("")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly here and the test above and below this: delete_cache("") is CacheValueError, delete_cache(None) is InvalidInputError, delete_cache(1) is ClientSdkError.


def test_delete_with_bad_cache_name_throws_exception(self):
with self.assertRaises(errors.ClientSdkError) as cm:
self.client.delete_cache(1)
self.assertEqual('{}'.format(cm.exception),
"Operation failed with error: 1 has type int, but expected one of: bytes, unicode")

def test_delete_cache_throws_permission_exception_for_bad_token(self):
with simple_cache_client.init(_BAD_AUTH_TOKEN,
_DEFAULT_TTL_SECONDS) as simple_cache:
with self.assertRaises(errors.PermissionError):
simple_cache.create_cache(str(uuid.uuid4()))

# list_caches

def test_list_caches_succeeds(self):
cache_name = str(uuid.uuid4())

caches = self.client.list_caches().caches()
cache_names = [cache.name() for cache in caches]
self.assertNotIn(cache_name, cache_names)

try:
self.client.create_cache(cache_name)

list_cache_resp = self.client.list_caches()
caches = list_cache_resp.caches()
cache_names = [cache.name() for cache in caches]
self.assertIn(cache_name, cache_names)
self.assertIsNone(list_cache_resp.next_token())
finally:
self.client.delete_cache(cache_name)

def test_list_caches_throws_permission_exception_for_bad_token(self):
with simple_cache_client.init(_BAD_AUTH_TOKEN,
_DEFAULT_TTL_SECONDS) as simple_cache:
with self.assertRaises(errors.PermissionError):
simple_cache.list_caches()

def test_list_caches_with_next_token_works(self):
# skip until pagination is actually implemented, see
# https://github.com/momentohq/control-plane-service/issues/83
self.skipTest("pagination not yet implemented")

# setting and getting

def test_set_and_get_with_hit(self):
key = str(uuid.uuid4())
value = str(uuid.uuid4())

set_resp = self.client.set(_TEST_CACHE_NAME, key, value)
self.assertEqual(set_resp.str_utf8(), value)
self.assertEqual(set_resp.bytes(), bytes(value, 'utf-8'))

get_resp = self.client.get(_TEST_CACHE_NAME, key)
self.assertEqual(get_resp.result(), CacheResult.HIT)
self.assertEqual(get_resp.str_utf8(), value)
self.assertEqual(get_resp.bytes(), bytes(value, 'utf-8'))

def test_set_and_get_with_byte_key_values(self):
key = uuid.uuid4().bytes
value = uuid.uuid4().bytes

set_resp = self.client.set(_TEST_CACHE_NAME, key, value)
self.assertEqual(set_resp.bytes(), value)

get_resp = self.client.get(_TEST_CACHE_NAME, key)
self.assertEqual(get_resp.result(), CacheResult.HIT)
self.assertEqual(get_resp.bytes(), value)

def test_get_returns_miss(self):
key = str(uuid.uuid4())

get_resp = self.client.get(_TEST_CACHE_NAME, key)
self.assertEqual(get_resp.result(), CacheResult.MISS)
self.assertEqual(get_resp.bytes(), None)
self.assertEqual(get_resp.str_utf8(), None)

def test_expires_items_after_ttl(self):
key = str(uuid.uuid4())
val = str(uuid.uuid4())
with simple_cache_client.init(_AUTH_TOKEN,
_DEFAULT_TTL_SECONDS) as simple_cache:
simple_cache.set(_TEST_CACHE_NAME, key, value)
get_resp = simple_cache.get(_TEST_CACHE_NAME, key)
1) as simple_cache:
simple_cache.set(_TEST_CACHE_NAME, key, val)

self.assertEqual(simple_cache.get(_TEST_CACHE_NAME, key).result(), CacheResult.HIT)

time.sleep(1.5)
self.assertEqual(simple_cache.get(_TEST_CACHE_NAME, key).result(), CacheResult.MISS)

def test_set_with_different_ttl(self):
key1 = str(uuid.uuid4())
key2 = str(uuid.uuid4())

self.client.set(_TEST_CACHE_NAME, key1, "1", 1)
self.client.set(_TEST_CACHE_NAME, key2, "2")

self.assertEqual(self.client.get(_TEST_CACHE_NAME, key1).result(), CacheResult.HIT)
self.assertEqual(self.client.get(_TEST_CACHE_NAME, key2).result(), CacheResult.HIT)

time.sleep(1.5)
self.assertEqual(self.client.get(_TEST_CACHE_NAME, key1).result(), CacheResult.MISS)
self.assertEqual(self.client.get(_TEST_CACHE_NAME, key2).result(), CacheResult.HIT)

# set

def test_set_with_non_existent_cache_name_throws_not_found(self):
cache_name = str(uuid.uuid4())
with self.assertRaises(errors.CacheNotFoundError):
self.client.set(cache_name, "foo", "bar")

def test_set_with_null_cache_name_throws_exception(self):
cache_name = str(uuid.uuid4())
with self.assertRaises(errors.InvalidInputError) as cm:
self.client.set(None, "foo", "bar")
self.assertEqual('{}'.format(cm.exception), "Cache Name cannot be None")

def test_set_with_empty_cache_name_throws_exception(self):
cache_name = str(uuid.uuid4())
with self.assertRaises(errors.PermissionError) as cm:
self.client.set("", "foo", "bar")
self.assertEqual('{}'.format(cm.exception), "Cache header is empty")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

set with cache_name as "" throwing a PermissionError is IMO confusing as a user.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


def test_set_with_null_key_throws_exception(self):
with self.assertRaises(errors.InvalidInputError):
self.client.set(_TEST_CACHE_NAME, None, "bar")

def test_set_with_null_value_throws_exception(self):
with self.assertRaises(errors.InvalidInputError):
self.client.set(_TEST_CACHE_NAME, "foo", None)

def test_set_negative_ttl_throws_exception(self):
with self.assertRaises(errors.InvalidInputError) as cm:
self.client.set(_TEST_CACHE_NAME, "foo", "bar", -1)
self.assertEqual('{}'.format(cm.exception), "TTL Seconds must be a non-negative integer")

def test_set_with_bad_cache_name_throws_exception(self):
with self.assertRaises(errors.ClientSdkError) as cm:
self.client.set(1, "foo", "bar")
self.assertEqual('{}'.format(cm.exception),
"Operation failed with error: Expected str, not <class 'int'>")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another case where an integer cache_name raises ClientSdkError, but empty string or None raises InvalidInputError.


def test_set_with_bad_key_throws_exception(self):
with self.assertRaises(errors.InvalidInputError) as cm:
self.client.set(_TEST_CACHE_NAME, 1, "bar")
self.assertEqual('{}'.format(cm.exception), "Unsupported type for key: <class 'int'>")

def test_set_with_bad_value_throws_exception(self):
with self.assertRaises(errors.InvalidInputError) as cm:
self.client.set(_TEST_CACHE_NAME, "foo", 1)
self.assertEqual('{}'.format(cm.exception), "Unsupported type for value: <class 'int'>")

def test_set_throws_permission_exception_for_bad_token(self):
with simple_cache_client.init(_BAD_AUTH_TOKEN,
_DEFAULT_TTL_SECONDS) as simple_cache:
with self.assertRaises(errors.PermissionError):
simple_cache.set(_TEST_CACHE_NAME, "foo", "bar")

# get

def test_get_with_non_existent_cache_name_throws_not_found(self):
cache_name = str(uuid.uuid4())
with self.assertRaises(errors.CacheNotFoundError):
self.client.get(cache_name, "foo")

def test_get_with_null_cache_name_throws_exception(self):
cache_name = str(uuid.uuid4())
with self.assertRaises(errors.InvalidInputError) as cm:
self.client.get(None, "foo")
self.assertEqual('{}'.format(cm.exception), "Cache Name cannot be None")

def test_get_with_empty_cache_name_throws_exception(self):
cache_name = str(uuid.uuid4())
with self.assertRaises(errors.PermissionError) as cm:
self.client.get("", "foo")
self.assertEqual('{}'.format(cm.exception), "Cache header is empty")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find get with cache_name as "" throwing a PermissionError confusing as a user.


def test_get_with_null_key_throws_exception(self):
with self.assertRaises(errors.InvalidInputError):
self.client.get(_TEST_CACHE_NAME, None)

def test_get_with_bad_cache_name_throws_exception(self):
with self.assertRaises(errors.ClientSdkError) as cm:
self.client.get(1, "foo")
self.assertEqual('{}'.format(cm.exception),
"Operation failed with error: Expected str, not <class 'int'>")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another case where an integer cache_name raises ClientSdkError, but empty string or None raises InvalidInputError.


self.assertEqual(get_resp.result(), CacheResult.HIT)
self.assertEqual(get_resp.str_utf8(), value)
def test_get_with_bad_key_throws_exception(self):
with self.assertRaises(errors.InvalidInputError) as cm:
self.client.get(_TEST_CACHE_NAME, 1)
self.assertEqual('{}'.format(cm.exception), "Unsupported type for key: <class 'int'>")

def test_get_throws_permission_exception_for_bad_token(self):
with simple_cache_client.init(_BAD_AUTH_TOKEN,
_DEFAULT_TTL_SECONDS) as simple_cache:
with self.assertRaises(errors.PermissionError):
simple_cache.get(_TEST_CACHE_NAME, "foo")

if __name__ == '__main__':
unittest.main()