diff options
author | Mitch Garnaat <mitch@garnaat.com> | 2012-02-23 12:22:11 -0800 |
---|---|---|
committer | Mitch Garnaat <mitch@garnaat.com> | 2012-02-23 12:22:11 -0800 |
commit | e19270e7bb28a462fc92a7badce6cdad47fd95b7 (patch) | |
tree | ea6af0f45fce5850ed3ca401e4314c653d516ed4 | |
parent | e3920747c251893b95a3dd9fb5c8f2741f4e29ea (diff) | |
parent | 40c3ae772b021649c7775dbe7024ceb6e081dcc5 (diff) | |
download | boto-requests_refactor.tar.gz |
Merging from master, resolving conflict in s3/key.py. Considerably more work is required here.requests_refactor
-rw-r--r-- | README.markdown | 4 | ||||
-rwxr-xr-x | bin/s3multiput | 2 | ||||
-rwxr-xr-x | bin/s3put | 2 | ||||
-rw-r--r-- | boto/__init__.py | 2 | ||||
-rw-r--r-- | boto/dynamodb/condition.py | 139 | ||||
-rw-r--r-- | boto/dynamodb/layer1.py | 25 | ||||
-rw-r--r-- | boto/dynamodb/layer2.py | 202 | ||||
-rw-r--r-- | boto/dynamodb/table.py | 23 | ||||
-rw-r--r-- | boto/dynamodb/types.py | 88 | ||||
-rw-r--r-- | boto/gs/key.py | 47 | ||||
-rw-r--r-- | boto/s3/key.py | 238 | ||||
-rw-r--r-- | boto/s3/multipart.py | 5 | ||||
-rw-r--r-- | boto/sdb/db/manager/sdbmanager.py | 16 | ||||
-rw-r--r-- | boto/sdb/db/property.py | 9 | ||||
-rwxr-xr-x | boto/storage_uri.py | 112 | ||||
-rw-r--r-- | boto/utils.py | 36 | ||||
-rw-r--r-- | docs/source/dynamodb_tut.rst | 16 | ||||
-rw-r--r-- | docs/source/index.rst | 5 | ||||
-rw-r--r-- | docs/source/ses_tut.rst | 171 | ||||
-rw-r--r-- | setup.py | 8 | ||||
-rw-r--r-- | tests/dynamodb/test_layer2.py | 28 | ||||
-rw-r--r-- | tests/s3/mock_storage_service.py | 72 | ||||
-rw-r--r-- | tests/s3/test_key.py | 321 | ||||
-rw-r--r-- | tests/s3/test_mfa.py | 91 | ||||
-rw-r--r-- | tests/s3/test_multipart.py | 32 | ||||
-rwxr-xr-x | tests/s3/test_resumable_uploads.py | 18 | ||||
-rw-r--r-- | tests/s3/test_versioning.py | 156 | ||||
-rwxr-xr-x | tests/test.py | 19 |
28 files changed, 1482 insertions, 405 deletions
diff --git a/README.markdown b/README.markdown index e8cdbc05..c636ad01 100644 --- a/README.markdown +++ b/README.markdown @@ -1,6 +1,6 @@ # boto -boto 2.2.1 -01-Feb-2012 +boto 2.2.2 +14-Feb-2012 ## Introduction diff --git a/bin/s3multiput b/bin/s3multiput index 095737ac..644ddc91 100755 --- a/bin/s3multiput +++ b/bin/s3multiput @@ -256,7 +256,7 @@ def main(): if not quiet: print 'Getting list of existing keys to check against' keys = [] - for key in b.list(): + for key in b.list(get_key_name(path, prefix)): keys.append(key.name) for root, dirs, files in os.walk(path): for ignore in ignore_dirs: @@ -162,7 +162,7 @@ def main(): if not quiet: print 'Getting list of existing keys to check against' keys = [] - for key in b.list(): + for key in b.list(get_key_name(path, prefix)): keys.append(key.name) for root, dirs, files in os.walk(path): for ignore in ignore_dirs: diff --git a/boto/__init__.py b/boto/__init__.py index 5ae663f6..b8593e25 100644 --- a/boto/__init__.py +++ b/boto/__init__.py @@ -31,7 +31,7 @@ import logging.config import urlparse from boto.exception import InvalidUriError -__version__ = '2.2.1-dev' +__version__ = '2.2.2-dev' Version = __version__ # for backware compatibility UserAgent = 'Boto/%s (%s)' % (__version__, sys.platform) diff --git a/boto/dynamodb/condition.py b/boto/dynamodb/condition.py new file mode 100644 index 00000000..b79b387f --- /dev/null +++ b/boto/dynamodb/condition.py @@ -0,0 +1,139 @@ +# Copyright (c) 2012 Mitch Garnaat http://garnaat.org/ +# Copyright (c) 2012 Amazon.com, Inc. or its affiliates. All Rights Reserved +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, dis- +# tribute, sublicense, and/or sell copies of the Software, and to permit +# persons to whom the Software is furnished to do so, subject to the fol- +# lowing conditions: +# +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABIL- +# ITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +# SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. +# + +from boto.dynamodb.types import get_dynamodb_type, dynamize_value, convert_num + +class Condition(object): + """ + Base class for conditions. Doesn't do a darn thing but allows + is to test if something is a Condition instance or not. + """ + + pass + +class ConditionNoArgs(Condition): + """ + Abstract class for Conditions that require no arguments, such + as NULL or NOT_NULL. + """ + + def __repr__(self): + return '%s' % self.__class__.__name__ + + def to_dict(self): + return {'ComparisonOperator': self.__class__.__name__} + +class ConditionOneArg(Condition): + """ + Abstract class for Conditions that require a single argument + such as EQ or NE. + """ + + def __init__(self, v1): + self.v1 = v1 + + def __repr__(self): + return '%s:%s' % (self.__class__.__name__, self.v1) + + def to_dict(self): + return {'AttributeValueList': [dynamize_value(self.v1)], + 'ComparisonOperator': self.__class__.__name__} + +class ConditionTwoArgs(Condition): + """ + Abstract class for Conditions that require two arguments. + The only example of this currently is BETWEEN. + """ + + def __init__(self, v1, v2): + Condition.__init__(self, v1) + self.v2 = v2 + + def __repr__(self): + return '%s(%s, %s)' % (self.__class__.__name__, self.v1, self.v2) + + def to_dict(self): + values = (self.v1, self.v2) + return {'AttributeValueList': [dynamize_value(v) for v in values], + 'ComparisonOperator': self.__class__.__name__} + +class EQ(ConditionOneArg): + + pass + +class NE(ConditionOneArg): + + pass + +class LE(ConditionOneArg): + + pass + +class LT(ConditionOneArg): + + pass + +class GE(ConditionOneArg): + + pass + +class GT(ConditionOneArg): + + pass + +class NULL(ConditionNoArgs): + + pass + +class NOT_NULL(ConditionNoArgs): + + pass + +class CONTAINS(ConditionOneArg): + + pass + +class NOT_CONTAINS(ConditionOneArg): + + pass + +class BEGINS_WITH(ConditionOneArg): + + pass + +class IN(ConditionOneArg): + + pass + +class BEGINS_WITH(ConditionOneArg): + + pass + +class BETWEEN(ConditionTwoArgs): + + pass + + + + + diff --git a/boto/dynamodb/layer1.py b/boto/dynamodb/layer1.py index 4443f7fa..2ff77ce4 100644 --- a/boto/dynamodb/layer1.py +++ b/boto/dynamodb/layer1.py @@ -151,14 +151,19 @@ class Layer1(AWSAuthConnection): def list_tables(self, limit=None, start_table=None): """ - Return a list of table names associated with the current account - and endpoint. + Returns a dictionary of results. The dictionary contains + a **TableNames** key whose value is a list of the table names. + The dictionary could also contain a **LastEvaluatedTableName** + key whose value would be the last table name returned if + the complete list of table names was not returned. This + value would then be passed as the ``start_table`` parameter on + a subsequent call to this method. :type limit: int :param limit: The maximum number of tables to return. :type start_table: str - :param limit: The name of the table that starts the + :param start_table: The name of the table that starts the list. If you ran a previous list_tables and not all results were returned, the response dict would include a LastEvaluatedTableName attribute. Use @@ -179,7 +184,7 @@ class Layer1(AWSAuthConnection): table was created. :type table_name: str - :param table_name: The name of the table to delete. + :param table_name: The name of the table to describe. """ data = {'TableName' : table_name} json_input = json.dumps(data) @@ -194,7 +199,7 @@ class Layer1(AWSAuthConnection): table will be ACTIVE. :type table_name: str - :param table_name: The name of the table to delete. + :param table_name: The name of the table to create. :type schema: dict :param schema: A Python version of the KeySchema data structure @@ -218,7 +223,7 @@ class Layer1(AWSAuthConnection): Updates the provisioned throughput for a given table. :type table_name: str - :param table_name: The name of the table to delete. + :param table_name: The name of the table to update. :type provisioned_throughput: dict :param provisioned_throughput: A Python version of the @@ -250,7 +255,7 @@ class Layer1(AWSAuthConnection): the supplied key. :type table_name: str - :param table_name: The name of the table to delete. + :param table_name: The name of the table containing the item. :type key: dict :param key: A Python version of the Key data structure @@ -307,7 +312,7 @@ class Layer1(AWSAuthConnection): expected rule. :type table_name: str - :param table_name: The name of the table to delete. + :param table_name: The name of the table in which to put the item. :type item: dict :param item: A Python version of the Item data structure @@ -385,7 +390,7 @@ class Layer1(AWSAuthConnection): expected rule. :type table_name: str - :param table_name: The name of the table to delete. + :param table_name: The name of the table containing the item. :type key: dict :param key: A Python version of the Key data structure @@ -422,7 +427,7 @@ class Layer1(AWSAuthConnection): which is passed as is to DynamoDB. :type table_name: str - :param table_name: The name of the table to delete. + :param table_name: The name of the table to query. :type hash_key_value: dict :param key: A DynamoDB-style HashKeyValue. diff --git a/boto/dynamodb/layer2.py b/boto/dynamodb/layer2.py index 858c3998..6415fb71 100644 --- a/boto/dynamodb/layer2.py +++ b/boto/dynamodb/layer2.py @@ -26,24 +26,7 @@ from boto.dynamodb.table import Table from boto.dynamodb.schema import Schema from boto.dynamodb.item import Item from boto.dynamodb.batch import BatchList - -""" -Some utility functions to deal with mapping Amazon DynamoDB types to -Python types and vice-versa. -""" - -def is_num(n): - return isinstance(n, (int, long, float, bool)) - -def is_str(n): - return isinstance(n, basestring) - -def convert_num(s): - if '.' in s: - n = float(s) - else: - n = int(s) - return n +from boto.dynamodb.types import get_dynamodb_type, dynamize_value, convert_num def item_object_hook(dct): """ @@ -51,6 +34,8 @@ def item_object_hook(dct): This hook will transform Amazon DynamoDB JSON responses to something that maps directly to native Python types. """ + if len(dct.keys()) > 1: + return dct if 'S' in dct: return dct['S'] if 'N' in dct: @@ -83,38 +68,33 @@ class Layer2(object): d[attr_name] = {"Action": action} else: d[attr_name] = {"Action": action, - "Value": self.dynamize_value(value)} + "Value": dynamize_value(value)} return d def dynamize_item(self, item): d = {} for attr_name in item: - d[attr_name] = self.dynamize_value(item[attr_name]) + d[attr_name] = dynamize_value(item[attr_name]) return d def dynamize_range_key_condition(self, range_key_condition): """ - Convert a range_key_condition parameter into the + Convert a layer2 range_key_condition parameter into the + structure required by Layer1. + """ + return range_key_condition.to_dict() + + def dynamize_scan_filter(self, scan_filter): + """ + Convert a layer2 scan_filter parameter into the structure required by Layer1. """ d = None - if range_key_condition: + if scan_filter: d = {} - for range_value in range_key_condition: - range_condition = range_key_condition[range_value] - if range_condition == 'BETWEEN': - if isinstance(range_value, tuple): - avl = [self.dynamize_value(v) for v in range_value] - else: - msg = 'BETWEEN condition requires a tuple value' - raise TypeError(msg) - elif isinstance(range_value, tuple): - msg = 'Tuple can only be supplied with BETWEEN condition' - raise TypeError(msg) - else: - avl = [self.dynamize_value(range_value)] - d = {'AttributeValueList': avl, - 'ComparisonOperator': range_condition} + for attr_name in scan_filter: + condition = scan_filter[attr_name] + d[attr_name] = condition.to_dict() return d def dynamize_expected_value(self, expected_value): @@ -132,7 +112,7 @@ class Layer2(object): elif attr_value is False: attr_value = {'Exists': False} else: - val = self.dynamize_value(expected_value[attr_name]) + val = dynamize_value(expected_value[attr_name]) attr_value = {'Value': val} d[attr_name] = attr_value return d @@ -145,10 +125,10 @@ class Layer2(object): d = None if last_evaluated_key: hash_key = last_evaluated_key['HashKeyElement'] - d = {'HashKeyElement': self.dynamize_value(hash_key)} + d = {'HashKeyElement': dynamize_value(hash_key)} if 'RangeKeyElement' in last_evaluated_key: range_key = last_evaluated_key['RangeKeyElement'] - d['RangeKeyElement'] = self.dynamize_value(range_key) + d['RangeKeyElement'] = dynamize_value(range_key) return d def dynamize_request_items(self, batch_list): @@ -177,53 +157,6 @@ class Layer2(object): d[batch.table.name] = batch_dict return d - def get_dynamodb_type(self, val): - """ - Take a scalar Python value and return a string representing - the corresponding Amazon DynamoDB type. If the value passed in is - not a supported type, raise a TypeError. - """ - if is_num(val): - dynamodb_type = 'N' - elif is_str(val): - dynamodb_type = 'S' - elif isinstance(val, (set, frozenset)): - if False not in map(is_num, val): - dynamodb_type = 'NS' - elif False not in map(is_str, val): - dynamodb_type = 'SS' - else: - raise TypeError('Unsupported type "%s" for value "%s"' % (type(val), val)) - return dynamodb_type - - def dynamize_value(self, val): - """ - Take a scalar Python value and return a dict consisting - of the Amazon DynamoDB type specification and the value that - needs to be sent to Amazon DynamoDB. If the type of the value - is not supported, raise a TypeError - """ - def _str(val): - """ - DynamoDB stores booleans as numbers. True is 1, False is 0. - This function converts Python booleans into DynamoDB friendly - representation. - """ - if isinstance(val, bool): - return str(int(val)) - return str(val) - - dynamodb_type = self.get_dynamodb_type(val) - if dynamodb_type == 'N': - val = {dynamodb_type : _str(val)} - elif dynamodb_type == 'S': - val = {dynamodb_type : val} - elif dynamodb_type == 'NS': - val = {dynamodb_type : [ str(n) for n in val]} - elif dynamodb_type == 'SS': - val = {dynamodb_type : [ n for n in val]} - return val - def build_key_from_values(self, schema, hash_key, range_key=None): """ Build a Key structure to be used for accessing items @@ -245,13 +178,13 @@ class Layer2(object): type defined in the schema. """ dynamodb_key = {} - dynamodb_value = self.dynamize_value(hash_key) + dynamodb_value = dynamize_value(hash_key) if dynamodb_value.keys()[0] != schema.hash_key_type: msg = 'Hashkey must be of type: %s' % schema.hash_key_type raise TypeError(msg) dynamodb_key['HashKeyElement'] = dynamodb_value - if range_key: - dynamodb_value = self.dynamize_value(range_key) + if range_key is not None: + dynamodb_value = dynamize_value(range_key) if dynamodb_value.keys()[0] != schema.range_key_type: msg = 'RangeKey must be of type: %s' % schema.range_key_type raise TypeError(msg) @@ -265,24 +198,22 @@ class Layer2(object): """ return BatchList(self) - def list_tables(self, limit=None, start_table=None): + def list_tables(self, limit=None): """ - Return a list of the names of all Tables associated with the + Return a list of the names of all tables associated with the current account and region. - TODO - Layer2 should probably automatically handle pagination. :type limit: int :param limit: The maximum number of tables to return. - - :type start_table: str - :param limit: The name of the table that starts the - list. If you ran a previous list_tables and not - all results were returned, the response dict would - include a LastEvaluatedTableName attribute. Use - that value here to continue the listing. """ - result = self.layer1.list_tables(limit, start_table) - return result['TableNames'] + tables = [] + while True: + result = self.layer1.list_tables(limit) + tables.extend(result.get('TableNames', [])) + start_table = result.get('LastEvaluatedTableName', None) + if not start_table: + break + return tables def describe_table(self, name): """ @@ -349,7 +280,7 @@ class Layer2(object): response = self.layer1.update_table(table.name, {'ReadCapacityUnits': read_units, 'WriteCapacityUnits': write_units}) - table.update_from_response(response['TableDescription']) + table.update_from_response(response) def delete_table(self, table): """ @@ -386,13 +317,13 @@ class Layer2(object): schema = {} hash_key = {} hash_key['AttributeName'] = hash_key_name - hash_key_type = self.get_dynamodb_type(hash_key_proto_value) + hash_key_type = get_dynamodb_type(hash_key_proto_value) hash_key['AttributeType'] = hash_key_type schema['HashKeyElement'] = hash_key if range_key_name and range_key_proto_value is not None: range_key = {} range_key['AttributeName'] = range_key_name - range_key_type = self.get_dynamodb_type(range_key_proto_value) + range_key_type = get_dynamodb_type(range_key_proto_value) range_key['AttributeType'] = range_key_type schema['RangeKeyElement'] = range_key return Schema(schema) @@ -575,18 +506,15 @@ class Layer2(object): type of the value must match the type defined in the schema for the table. - :type range_key_condition: dict - :param range_key_condition: A dict where the key is either - a scalar value appropriate for the RangeKey in the schema - of the database or a tuple of such values. The value - associated with this key in the dict will be one of the - following conditions: + :type range_key_condition: :class:`boto.dynamodb.condition.Condition` + :param range_key_condition: A Condition object. + Condition object can be one of the following types: - 'EQ'|'LE'|'LT'|'GE'|'GT'|'BEGINS_WITH'|'BETWEEN' + EQ|LE|LT|GE|GT|BEGINS_WITH|BETWEEN - The only condition which expects or will accept a tuple - of values is 'BETWEEN', otherwise a scalar value should - be used as the key in the dict. + The only condition which expects or will accept two + values is 'BETWEEN', otherwise a single value should + be passed to the Condition constructor. :type attributes_to_get: list :param attributes_to_get: A list of attribute names. @@ -629,10 +557,15 @@ class Layer2(object): :rtype: generator """ - rkc = self.dynamize_range_key_condition(range_key_condition) + if range_key_condition: + rkc = self.dynamize_range_key_condition(range_key_condition) + else: + rkc = None response = True n = 0 while response: + if max_results and n == max_results: + break if response is True: pass elif response.has_key("LastEvaluatedKey"): @@ -641,7 +574,7 @@ class Layer2(object): else: break response = self.layer1.query(table.name, - self.dynamize_value(hash_key), + dynamize_value(hash_key), rkc, attributes_to_get, request_limit, consistent_read, scan_index_forward, exclusive_start_key, @@ -656,16 +589,30 @@ class Layer2(object): attributes_to_get=None, request_limit=None, max_results=None, count=False, exclusive_start_key=None, item_class=Item): """ - Perform a scan of DynamoDB. This version is currently punting - and expecting you to provide a full and correct JSON body - which is passed as is to DynamoDB. + Perform a scan of DynamoDB. - :type table: Table - :param table: The table to scan from - - :type scan_filter: dict - :param scan_filter: A Python version of the - ScanFilter data structure. + :type table: :class:`boto.dynamodb.table.Table` + :param table: The Table object that is being scanned. + + :type scan_filter: A dict + :param scan_filter: A dictionary where the key is the + attribute name and the value is a + :class:`boto.dynamodb.condition.Condition` object. + Valid Condition objects include: + + * EQ - equal (1) + * NE - not equal (1) + * LE - less than or equal (1) + * LT - less than (1) + * GE - greater than or equal (1) + * GT - greater than (1) + * NOT_NULL - attribute exists (0, use None) + * NULL - attribute does not exist (0, use None) + * CONTAINS - substring or value in list (1) + * NOT_CONTAINS - absence of substring or value in list (1) + * BEGINS_WITH - substring prefix (1) + * IN - exact match in list (N) + * BETWEEN - >= first value, <= second value (2) :type attributes_to_get: list :param attributes_to_get: A list of attribute names. @@ -704,6 +651,7 @@ class Layer2(object): :rtype: generator """ + sf = self.dynamize_scan_filter(scan_filter) response = True n = 0 while response: @@ -714,7 +662,7 @@ class Layer2(object): else: break - response = self.layer1.scan(table.name, scan_filter, + response = self.layer1.scan(table.name, sf, attributes_to_get,request_limit, count, exclusive_start_key, object_hook=item_object_hook) diff --git a/boto/dynamodb/table.py b/boto/dynamodb/table.py index 152c2cb8..7c8e5ea8 100644 --- a/boto/dynamodb/table.py +++ b/boto/dynamodb/table.py @@ -307,9 +307,26 @@ class Table(object): and expensive operation, and should be avoided if at all possible. - :type scan_filter: dict - :param scan_filter: A Python version of the - ScanFilter data structure. + :type scan_filter: A list of tuples + :param scan_filter: A list of tuples where each tuple consists + of an attribute name, a comparison operator, and either + a scalar or tuple consisting of the values to compare + the attribute to. Valid comparison operators are shown below + along with the expected number of values that should be supplied. + + * EQ - equal (1) + * NE - not equal (1) + * LE - less than or equal (1) + * LT - less than (1) + * GE - greater than or equal (1) + * GT - greater than (1) + * NOT_NULL - attribute exists (0, use None) + * NULL - attribute does not exist (0, use None) + * CONTAINS - substring or value in list (1) + * NOT_CONTAINS - absence of substring or value in list (1) + * BEGINS_WITH - substring prefix (1) + * IN - exact match in list (N) + * BETWEEN - >= first value, <= second value (2) :type attributes_to_get: list :param attributes_to_get: A list of attribute names. diff --git a/boto/dynamodb/types.py b/boto/dynamodb/types.py new file mode 100644 index 00000000..723d33d8 --- /dev/null +++ b/boto/dynamodb/types.py @@ -0,0 +1,88 @@ +# Copyright (c) 2011 Mitch Garnaat http://garnaat.org/ +# Copyright (c) 2011 Amazon.com, Inc. or its affiliates. All Rights Reserved +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, dis- +# tribute, sublicense, and/or sell copies of the Software, and to permit +# persons to whom the Software is furnished to do so, subject to the fol- +# lowing conditions: +# +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABIL- +# ITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +# SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. +# +""" +Some utility functions to deal with mapping Amazon DynamoDB types to +Python types and vice-versa. +""" + +def is_num(n): + return isinstance(n, (int, long, float, bool)) + +def is_str(n): + return isinstance(n, basestring) + +def convert_num(s): + if '.' in s: + n = float(s) + else: + n = int(s) + return n + +def get_dynamodb_type(val): + """ + Take a scalar Python value and return a string representing + the corresponding Amazon DynamoDB type. If the value passed in is + not a supported type, raise a TypeError. + """ + dynamodb_type = None + if is_num(val): + dynamodb_type = 'N' + elif is_str(val): + dynamodb_type = 'S' + elif isinstance(val, (set, frozenset)): + if False not in map(is_num, val): + dynamodb_type = 'NS' + elif False not in map(is_str, val): + dynamodb_type = 'SS' + if dynamodb_type is None: + raise TypeError('Unsupported type "%s" for value "%s"' % (type(val), val)) + return dynamodb_type + +def dynamize_value(val): + """ + Take a scalar Python value and return a dict consisting + of the Amazon DynamoDB type specification and the value that + needs to be sent to Amazon DynamoDB. If the type of the value + is not supported, raise a TypeError + """ + def _str(val): + """ + DynamoDB stores booleans as numbers. True is 1, False is 0. + This function converts Python booleans into DynamoDB friendly + representation. + """ + if isinstance(val, bool): + return str(int(val)) + return str(val) + + dynamodb_type = get_dynamodb_type(val) + if dynamodb_type == 'N': + val = {dynamodb_type : _str(val)} + elif dynamodb_type == 'S': + val = {dynamodb_type : val} + elif dynamodb_type == 'NS': + val = {dynamodb_type : [ str(n) for n in val]} + elif dynamodb_type == 'SS': + val = {dynamodb_type : [ n for n in val]} + return val + diff --git a/boto/gs/key.py b/boto/gs/key.py index de6e6f44..21532d38 100644 --- a/boto/gs/key.py +++ b/boto/gs/key.py @@ -20,6 +20,7 @@ # IN THE SOFTWARE. import StringIO +from boto.exception import BotoClientError from boto.s3.key import Key as S3Key class Key(S3Key): @@ -110,7 +111,7 @@ class Key(S3Key): def set_contents_from_file(self, fp, headers=None, replace=True, cb=None, num_cb=10, policy=None, md5=None, - res_upload_handler=None): + res_upload_handler=None, size=None): """ Store an object in GS using the name of the Key object as the key in GS and the contents of the file pointed to by 'fp' as the @@ -158,12 +159,31 @@ class Key(S3Key): :param res_upload_handler: If provided, this handler will perform the upload. + :type size: int + :param size: (optional) The Maximum number of bytes to read from + the file pointer (fp). This is useful when uploading + a file in multiple parts where you are splitting the + file up into different ranges to be uploaded. If not + specified, the default behaviour is to read all bytes + from the file pointer. Less bytes may be available. + Notes: + + 1. The "size" parameter currently cannot be used when + a resumable upload handler is given but is still + useful for uploading part of a file as implemented + by the parent class. + 2. At present Google Cloud Storage does not support + multipart uploads. + TODO: At some point we should refactor the Bucket and Key classes, to move functionality common to all providers into a parent class, and provider-specific functionality into subclasses (rather than just overriding/sharing code the way it currently works). """ provider = self.bucket.connection.provider + if res_upload_handler and size: + # could use size instead of file_length if provided but... + raise BotoClientError('"size" param not supported for resumable uploads.') headers = headers or {} if policy: headers[provider.acl_header] = policy @@ -171,25 +191,34 @@ class Key(S3Key): self.path = fp.name if self.bucket != None: if not md5: - md5 = self.compute_md5(fp) + # compute_md5() and also set self.size to actual + # size of the bytes read computing the md5. + md5 = self.compute_md5(fp, size) + # adjust size if required + size = self.size + elif size: + self.size = size else: - # Even if md5 is provided, still need to set size of content. - fp.seek(0, 2) - self.size = fp.tell() - fp.seek(0) + # If md5 is provided, still need to size so + # calculate based on bytes to end of content + spos = fp.tell() + fp.seek(0, os.SEEK_END) + self.size = fp.tell() - spos + fp.seek(spos) + size = self.size self.md5 = md5[0] self.base64md5 = md5[1] + if self.name == None: self.name = self.md5 if not replace: - k = self.bucket.lookup(self.name) - if k: + if self.bucket.lookup(self.name): return if res_upload_handler: res_upload_handler.send_file(self, fp, headers, cb, num_cb) else: # Not a resumable transfer so use basic send_file mechanism. - self.send_file(fp, headers, cb, num_cb) + self.send_file(fp, headers, cb, num_cb, size=size) def set_contents_from_filename(self, filename, headers=None, replace=True, cb=None, num_cb=10, policy=None, md5=None, diff --git a/boto/s3/key.py b/boto/s3/key.py index 7bc700aa..15e32281 100644 --- a/boto/s3/key.py +++ b/boto/s3/key.py @@ -26,6 +26,7 @@ import re import rfc822 import StringIO import base64 +import math import urllib import boto.utils from boto.exception import BotoClientError @@ -465,12 +466,17 @@ class Key(object): expires_in_absolute) def send_file(self, fp, headers=None, cb=None, num_cb=10, - query_args=None, chunked_transfer=False): + query_args=None, chunked_transfer=False, size=None): """ Upload a file to a key into a bucket on S3. :type fp: file - :param fp: The file pointer to upload + :param fp: The file pointer to upload. The file pointer must point + point at the offset from which you wish to upload. + ie. if uploading the full file, it should point at the + start of the file. Normally when a file is opened for + reading, the fp will point at the first byte. See the + bytes parameter below for more info. :type headers: dict :param headers: The headers to pass along with the PUT request @@ -491,21 +497,44 @@ class Key(object): transfer. Providing a negative integer will cause your callback to be called with each buffer read. + :type size: int + :param size: (optional) The Maximum number of bytes to read from + the file pointer (fp). This is useful when uploading + a file in multiple parts where you are splitting the + file up into different ranges to be uploaded. If not + specified, the default behaviour is to read all bytes + from the file pointer. Less bytes may be available. """ provider = self.bucket.connection.provider + try: + spos = fp.tell() + except IOError: + spos = None + self.read_from_stream = False # TODO: Replace this with requests implementation, once it exists. def sender(http_conn, method, path, data, headers): + # This function is called repeatedly for temporary retries + # so we must be sure the file pointer is pointing at the + # start of the data. + if spos is not None and spos != fp.tell(): + fp.seek(spos) + elif spos is None and self.read_from_stream: + # if seek is not supported, and we've read from this + # stream already, then we need to abort retries to + # avoid setting bad data. + raise provider.storage_data_error( + 'Cannot retry failed request. fp does not support seeking.') + http_conn.putrequest(method, path) for key in headers: http_conn.putheader(key, headers[key]) http_conn.endheaders() - if chunked_transfer: - # MD5 for the stream has to be calculated on the fly, as - # we don't know the size of the stream before hand. + if chunked_transfer and not self.base64md5: + # MD5 for the stream has to be calculated on the fly. m = md5() else: - fp.seek(0) + m = None save_debug = self.bucket.connection.debug self.bucket.connection.debug = 0 @@ -515,48 +544,74 @@ class Key(object): # Use the getattr approach to allow this to work in AppEngine. if getattr(http_conn, 'debuglevel', 0) < 3: http_conn.set_debuglevel(0) + + data_len = 0 if cb: - if chunked_transfer: + if size: + cb_size = size + elif self.size: + cb_size = self.size + else: + cb_size = 0 + if chunked_transfer and cb_size == 0: # For chunked Transfer, we call the cb for every 1MB - # of data transferred. + # of data transferred, except when we know size. cb_count = (1024 * 1024)/self.BufferSize - self.size = 0 - elif num_cb > 2: - cb_count = self.size / self.BufferSize / (num_cb-2) + elif num_cb > 1: + cb_count = int(math.ceil(cb_size/self.BufferSize/(num_cb-1.0))) elif num_cb < 0: cb_count = -1 else: cb_count = 0 - i = total_bytes = 0 - cb(total_bytes, self.size) - l = fp.read(self.BufferSize) - while len(l) > 0: + i = 0 + cb(data_len, cb_size) + + bytes_togo = size + if bytes_togo and bytes_togo < self.BufferSize: + chunk = fp.read(bytes_togo) + else: + chunk = fp.read(self.BufferSize) + if spos is None: + # read at least something from a non-seekable fp. + self.read_from_stream = True + while chunk: + chunk_len = len(chunk) + data_len += chunk_len if chunked_transfer: - http_conn.send('%x;\r\n' % len(l)) - http_conn.send(l) + http_conn.send('%x;\r\n' % chunk_len) + http_conn.send(chunk) http_conn.send('\r\n') else: - http_conn.send(l) + http_conn.send(chunk) + if m: + m.update(chunk) + if bytes_togo: + bytes_togo -= chunk_len + if bytes_togo <= 0: + break if cb: - total_bytes += len(l) i += 1 if i == cb_count or cb_count == -1: - cb(total_bytes, self.size) + cb(data_len, cb_size) i = 0 - if chunked_transfer: - m.update(l) - l = fp.read(self.BufferSize) + if bytes_togo and bytes_togo < self.BufferSize: + chunk = fp.read(bytes_togo) + else: + chunk = fp.read(self.BufferSize) + + self.size = data_len if chunked_transfer: http_conn.send('0\r\n') + if m: + # Use the chunked trailer for the digest + hd = m.hexdigest() + self.md5, self.base64md5 = self.get_md5_from_hexdigest(hd) + # http_conn.send("Content-MD5: %s\r\n" % self.base64md5) http_conn.send('\r\n') - if cb: - self.size = total_bytes - # Get the md5 which is calculated on the fly. - self.md5 = m.hexdigest() - else: - fp.seek(0) - if cb: - cb(total_bytes, self.size) + + if cb and (cb_count <= 1 or i > 0) and data_len > 0: + cb(data_len, cb_size) + response = http_conn.getresponse() body = response.content http_conn.set_debuglevel(save_debug) @@ -580,8 +635,6 @@ class Key(object): else: headers = headers.copy() headers['User-Agent'] = UserAgent - if self.base64md5: - headers['Content-MD5'] = self.base64md5 if self.storage_class != 'STANDARD': headers[provider.storage_class_header] = self.storage_class if headers.has_key('Content-Encoding'): @@ -603,7 +656,13 @@ class Key(object): headers['Content-Type'] = self.content_type else: headers['Content-Type'] = self.content_type - if not chunked_transfer: + if self.base64md5: + headers['Content-MD5'] = self.base64md5 + if chunked_transfer: + headers['Transfer-Encoding'] = 'chunked' + #if not self.base64md5: + # headers['Trailer'] = "Content-MD5" + else: headers['Content-Length'] = str(self.size) headers['Expect'] = '100-Continue' headers = boto.utils.merge_meta(headers, self.metadata, provider) @@ -613,22 +672,29 @@ class Key(object): query_args=query_args) self.handle_version_headers(resp, force=True) - def compute_md5(self, fp): + def compute_md5(self, fp, size=None): """ :type fp: file :param fp: File pointer to the file to MD5 hash. The file pointer - will be reset to the beginning of the file before the + will be reset to the same position before the method returns. + :type size: int + :param size: (optional) The Maximum number of bytes to read from + the file pointer (fp). This is useful when uploading + a file in multiple parts where the file is being + split inplace into different parts. Less bytes may + be available. + :rtype: tuple :return: A tuple containing the hex digest version of the MD5 hash as the first element and the base64 encoded version of the plain digest as the second element. """ - tup = compute_md5(fp) - # Returned values are MD5 hash, base64 encoded MD5 hash, and file size. + tup = compute_md5(fp, size=size) + # Returned values are MD5 hash, base64 encoded MD5 hash, and data size. # The internal implementation of compute_md5() needs to return the - # file size but we don't want to return that value to the external + # data size but we don't want to return that value to the external # caller because it changes the class interface (i.e. it might # break some code) so we consume the third tuple value here and # return the remainder of the tuple to the caller, thereby preserving @@ -638,7 +704,8 @@ class Key(object): def set_contents_from_stream(self, fp, headers=None, replace=True, cb=None, num_cb=10, policy=None, - reduced_redundancy=False, query_args=None): + reduced_redundancy=False, query_args=None, + size=None): """ Store an object using the name of the Key object as the key in cloud and the contents of the data stream pointed to by 'fp' as @@ -684,6 +751,13 @@ class Key(object): REDUCED_REDUNDANCY. The Reduced Redundancy Storage (RRS) feature of S3, provides lower redundancy at lower storage cost. + :type size: int + :param size: (optional) The Maximum number of bytes to read from + the file pointer (fp). This is useful when uploading + a file in multiple parts where you are splitting the + file up into different ranges to be uploaded. If not + specified, the default behaviour is to read all bytes + from the file pointer. Less bytes may be available. """ provider = self.bucket.connection.provider @@ -701,9 +775,6 @@ class Key(object): if policy: headers[provider.acl_header] = policy - # Set the Transfer Encoding for Streams. - headers['Transfer-Encoding'] = 'chunked' - if reduced_redundancy: self.storage_class = 'REDUCED_REDUNDANCY' if provider.storage_class_header: @@ -711,16 +782,15 @@ class Key(object): if self.bucket is not None: if not replace: - k = self.bucket.lookup(self.name) - if k: + if self.bucket.lookup(self.name): return self.send_file(fp, headers, cb, num_cb, query_args, - chunked_transfer=True) + chunked_transfer=True, size=size) def set_contents_from_file(self, fp, headers=None, replace=True, cb=None, num_cb=10, policy=None, md5=None, reduced_redundancy=False, query_args=None, - encrypt_key=False): + encrypt_key=False, size=None): """ Store an object in S3 using the name of the Key object as the key in S3 and the contents of the file pointed to by 'fp' as the @@ -781,10 +851,17 @@ class Key(object): be encrypted on the server-side by S3 and will be stored in an encrypted form while at rest in S3. + + :type size: int + :param size: (optional) The Maximum number of bytes to read from + the file pointer (fp). This is useful when uploading + a file in multiple parts where you are splitting the + file up into different ranges to be uploaded. If not + specified, the default behaviour is to read all bytes + from the file pointer. Less bytes may be available. """ provider = self.bucket.connection.provider - if headers is None: - headers = {} + headers = headers or {} if policy: headers[provider.acl_header] = policy if encrypt_key: @@ -798,23 +875,43 @@ class Key(object): # What if different providers provide different classes? if hasattr(fp, 'name'): self.path = fp.name + if self.bucket != None: - if not md5: - md5 = self.compute_md5(fp) + if not md5 and provider.supports_chunked_transfer(): + # defer md5 calculation to on the fly and + # we don't know anything about size yet. + chunked_transfer = True + self.size = None else: - # even if md5 is provided, still need to set size of content - fp.seek(0, 2) - self.size = fp.tell() - fp.seek(0) - self.md5 = md5[0] - self.base64md5 = md5[1] + chunked_transfer = False + if not md5: + # compute_md5() and also set self.size to actual + # size of the bytes read computing the md5. + md5 = self.compute_md5(fp, size) + # adjust size if required + size = self.size + elif size: + self.size = size + else: + # If md5 is provided, still need to size so + # calculate based on bytes to end of content + spos = fp.tell() + fp.seek(0, os.SEEK_END) + self.size = fp.tell() - spos + fp.seek(spos) + size = self.size + self.md5 = md5[0] + self.base64md5 = md5[1] + if self.name == None: self.name = self.md5 if not replace: - k = self.bucket.lookup(self.name) - if k: + if self.bucket.lookup(self.name): return - self.send_file(fp, headers, cb, num_cb, query_args) + + self.send_file(fp, headers=headers, cb=cb, num_cb=num_cb, + query_args=query_args, chunked_transfer=chunked_transfer, + size=size) def set_contents_from_filename(self, filename, headers=None, replace=True, cb=None, num_cb=10, policy=None, md5=None, @@ -1001,6 +1098,7 @@ class Key(object): cb_count = 0 i = total_bytes = 0 cb(total_bytes, self.size) + save_debug = self.bucket.connection.debug if self.bucket.connection.debug == 1: self.bucket.connection.debug = 0 @@ -1008,6 +1106,9 @@ class Key(object): query_args = [] if torrent: query_args.append('torrent') + m = None + else: + m = md5() # If a version_id is passed in, use that. If not, check to see # if the Key object has an explicit version_id and, if so, use that. # Otherwise, don't pass a version_id query param. @@ -1023,16 +1124,25 @@ class Key(object): query_args = '&'.join(query_args) self.open('r', headers, query_args=query_args, override_num_retries=override_num_retries) + for bytes in self.read(size=self.BufferSize): fp.write(bytes) + data_len += len(bytes) + if m: + m.update(bytes) if cb: - total_bytes += len(bytes) + if cb_size > 0 and data_len >= cb_size: + break i += 1 if i == cb_count or cb_count == -1: - cb(total_bytes, self.size) + cb(data_len, cb_size) i = 0 - if cb: - cb(total_bytes, self.size) + if cb and (cb_count <= 1 or i > 0) and data_len > 0: + cb(data_len, cb_size) + if m: + self.md5 = m.hexdigest() + if self.size is None and not torrent and not headers.has_key("Range"): + self.size = data_len self.close() self.bucket.connection.debug = save_debug diff --git a/boto/s3/multipart.py b/boto/s3/multipart.py index a7cf5ad8..dde83464 100644 --- a/boto/s3/multipart.py +++ b/boto/s3/multipart.py @@ -211,7 +211,8 @@ class MultiPartUpload(object): return self._parts def upload_part_from_file(self, fp, part_num, headers=None, replace=True, - cb=None, num_cb=10, policy=None, md5=None): + cb=None, num_cb=10, policy=None, md5=None, + size=None): """ Upload another part of this MultiPart Upload. @@ -230,7 +231,7 @@ class MultiPartUpload(object): key = self.bucket.new_key(self.key_name) key.set_contents_from_file(fp, headers, replace, cb, num_cb, policy, md5, reduced_redundancy=False, - query_args=query_args) + query_args=query_args, size=size) def copy_part_from_key(self, src_bucket_name, src_key_name, part_num, start=None, end=None): diff --git a/boto/sdb/db/manager/sdbmanager.py b/boto/sdb/db/manager/sdbmanager.py index 8218f816..1a4baa8f 100644 --- a/boto/sdb/db/manager/sdbmanager.py +++ b/boto/sdb/db/manager/sdbmanager.py @@ -257,12 +257,22 @@ class SDBConverter(object): def encode_datetime(self, value): if isinstance(value, str) or isinstance(value, unicode): return value - return value.strftime(ISO8601) + if isinstance(value, date): + return value.isoformat() + else: + return value.strftime(ISO8601) def decode_datetime(self, value): + """Handles both Dates and DateTime objects""" + if value is None: + return value try: - return datetime.strptime(value, ISO8601) - except: + if "T" in value: + return datetime.strptime(value, ISO8601) + else: + value = value.split("-") + return date(int(value[0]), int(value[1]), int(value[2])) + except Exception, e: return None def encode_date(self, value): diff --git a/boto/sdb/db/property.py b/boto/sdb/db/property.py index 1929a027..698ec762 100644 --- a/boto/sdb/db/property.py +++ b/boto/sdb/db/property.py @@ -375,6 +375,9 @@ class FloatProperty(Property): return value is None class DateTimeProperty(Property): + """This class handles both the datetime.datetime object + And the datetime.date objects. It can return either one, + depending on the value stored in the database""" data_type = datetime.datetime type_name = 'DateTime' @@ -391,11 +394,11 @@ class DateTimeProperty(Property): return Property.default_value(self) def validate(self, value): - value = super(DateTimeProperty, self).validate(value) if value == None: return - if not isinstance(value, self.data_type): - raise TypeError, 'Validation Error, expecting %s, got %s' % (self.data_type, type(value)) + if isinstance(value, datetime.date): + return value + return super(DateTimeProperty, self).validate(value) def get_value_for_datastore(self, model_instance): if self.auto_now: diff --git a/boto/storage_uri.py b/boto/storage_uri.py index 299eb852..5f14500d 100755 --- a/boto/storage_uri.py +++ b/boto/storage_uri.py @@ -64,8 +64,11 @@ class StorageUri(object): def check_response(self, resp, level, uri): if resp is None: - raise InvalidUriError('Attempt to get %s for "%s" failed. This ' - 'probably indicates the URI is invalid' % + raise InvalidUriError('Attempt to get %s for "%s" failed.\nThis ' + 'can happen if the URI refers to a non-' + 'existent object or if you meant to\noperate ' + 'on a directory (e.g., leaving off -R option ' + 'on gsutil cp, mv, or ls of a\nbucket)' % (level, uri)) def connect(self, access_key_id=None, secret_access_key=None, **kwargs): @@ -188,6 +191,8 @@ class BucketStorageUri(StorageUri): Callers should instantiate this class by calling boto.storage_uri(). """ + delim = '/' + def __init__(self, scheme, bucket_name=None, object_name=None, debug=0, connection_args=None, suppress_consec_slashes=True): """Instantiate a BucketStorageUri from scheme,bucket,object tuple. @@ -237,7 +242,8 @@ class BucketStorageUri(StorageUri): raise InvalidUriError('clone_replace_name() on bucket-less URI %s' % self.uri) return BucketStorageUri( - self.scheme, self.bucket_name, new_name, self.debug, + self.scheme, bucket_name=self.bucket_name, object_name=new_name, + debug=self.debug, suppress_consec_slashes=self.suppress_consec_slashes) def get_acl(self, validate=True, headers=None, version_id=None): @@ -295,8 +301,8 @@ class BucketStorageUri(StorageUri): bucket.add_group_email_grant(permission, email_address, recursive, headers) else: - raise InvalidUriError('add_group_email_grant() on bucket-less URI %s' % - self.uri) + raise InvalidUriError('add_group_email_grant() on bucket-less URI ' + '%s' % self.uri) def add_email_grant(self, permission, email_address, recursive=False, validate=True, headers=None): @@ -332,21 +338,49 @@ class BucketStorageUri(StorageUri): bucket = self.get_bucket(headers) return bucket.list_grants(headers) + def is_file_uri(self): + """Returns True if this URI names a file or directory.""" + return False + + def is_cloud_uri(self): + """Returns True if this URI names a bucket or object.""" + return True + def names_container(self): - """Returns True if this URI names a bucket (vs. an object). """ - return not self.object_name + Returns True if this URI names a directory or bucket. Will return + False for bucket subdirs; providing bucket subdir semantics needs to + be done by the caller (like gsutil does). + """ + return bool(not self.object_name) def names_singleton(self): - """Returns True if this URI names an object (vs. a bucket). - """ - return self.object_name + """Returns True if this URI names a file or object.""" + return bool(self.object_name) - def is_file_uri(self): + def names_directory(self): + """Returns True if this URI names a directory.""" return False - def is_cloud_uri(self): - return True + def names_provider(self): + """Returns True if this URI names a provider.""" + return bool(not self.bucket_name) + + def names_bucket(self): + """Returns True if this URI names a bucket.""" + return self.names_container() + + def names_file(self): + """Returns True if this URI names a file.""" + return False + + def names_object(self): + """Returns True if this URI names an object.""" + return self.names_singleton() + + def is_stream(self): + """Returns True if this URI represents input/output stream.""" + return False def create_bucket(self, headers=None, location='', policy=None): if self.bucket_name is None: @@ -452,6 +486,8 @@ class FileStorageUri(StorageUri): See file/README about how we map StorageUri operations onto a file system. """ + delim = os.sep + def __init__(self, object_name, debug, is_stream=False): """Instantiate a FileStorageUri from a path name. @@ -481,34 +517,48 @@ class FileStorageUri(StorageUri): """ return FileStorageUri(new_name, self.debug, self.stream) + def is_file_uri(self): + """Returns True if this URI names a file or directory.""" + return True + + def is_cloud_uri(self): + """Returns True if this URI names a bucket or object.""" + return False + def names_container(self): - """Returns True if this URI is not representing input/output stream - and names a directory. - """ - if not self.stream: - return os.path.isdir(self.object_name) - else: - return False + """Returns True if this URI names a directory or bucket.""" + return self.names_directory() def names_singleton(self): - """Returns True if this URI names a file or - if URI represents input/output stream. - """ + """Returns True if this URI names a file (or stream) or object.""" + return not self.names_container() + + def names_directory(self): + """Returns True if this URI names a directory.""" if self.stream: - return True - else: - return os.path.isfile(self.object_name) + return False + return os.path.isdir(self.object_name) - def is_file_uri(self): - return True + def names_provider(self): + """Returns True if this URI names a provider.""" + return False - def is_cloud_uri(self): + def names_bucket(self): + """Returns True if this URI names a bucket.""" + return False + + def names_file(self): + """Returns True if this URI names a file.""" + return self.names_singleton() + + def names_object(self): + """Returns True if this URI names an object.""" return False def is_stream(self): - """Retruns True if this URI represents input/output stream. + """Returns True if this URI represents input/output stream. """ - return self.stream + return bool(self.stream) def close(self): """Closes the underlying file. diff --git a/boto/utils.py b/boto/utils.py index d84a7d02..4817dd33 100644 --- a/boto/utils.py +++ b/boto/utils.py @@ -704,34 +704,52 @@ def guess_mime_type(content, deftype): break return(rtype) -def compute_md5(fp, buf_size=8192): +def compute_md5(fp, buf_size=8192, size=None): """ Compute MD5 hash on passed file and return results in a tuple of values. :type fp: file :param fp: File pointer to the file to MD5 hash. The file pointer - will be reset to the beginning of the file before the + will be reset to its current location before the method returns. :type buf_size: integer :param buf_size: Number of bytes per read request. + :type size: int + :param size: (optional) The Maximum number of bytes to read from + the file pointer (fp). This is useful when uploading + a file in multiple parts where the file is being + split inplace into different parts. Less bytes may + be available. + :rtype: tuple :return: A tuple containing the hex digest version of the MD5 hash as the first element, the base64 encoded version of the - plain digest as the second element and the file size as + plain digest as the second element and the data size as the third element. """ m = md5() - fp.seek(0) - s = fp.read(buf_size) + spos = fp.tell() + if size and size < buf_size: + s = fp.read(size) + else: + s = fp.read(buf_size) while s: m.update(s) - s = fp.read(buf_size) + if size: + size -= len(s) + if size <= 0: + break + if size and size < buf_size: + s = fp.read(size) + else: + s = fp.read(buf_size) hex_md5 = m.hexdigest() base64md5 = base64.encodestring(m.digest()) if base64md5[-1] == '\n': base64md5 = base64md5[0:-1] - file_size = fp.tell() - fp.seek(0) - return (hex_md5, base64md5, file_size) + # data_size based on bytes read. + data_size = fp.tell() - spos + fp.seek(spos) + return (hex_md5, base64md5, data_size) diff --git a/docs/source/dynamodb_tut.rst b/docs/source/dynamodb_tut.rst index 40ff3fa2..3e641227 100644 --- a/docs/source/dynamodb_tut.rst +++ b/docs/source/dynamodb_tut.rst @@ -23,10 +23,12 @@ To do so, the most straight forward way is the following:: <boto.dynamodb.layer2.Layer2 object at 0x3fb3090>
Bear in mind that if you have your credentials in boto config in your home
-directory, the two keyword arguments in the call above are not needed. Also
-important to note is that just as any other AWS service, DynamoDB is
-region-specific and as such you might want to specify which region to connect
-to, by default, it'll connect to the US-EAST-1 region.
+directory, the two keyword arguments in the call above are not needed. More
+details on configuration can be found in :doc:`boto_config_tut`.
+
+.. note:: At this
+ time, Amazon DynamoDB is available only in the US-EAST-1 region. The
+ ``connect_dynamodb`` method automatically connect to that region.
The :py:func:`boto.connect_dynamodb` functions returns a
:py:class:`boto.dynamodb.layer2.Layer2` instance, which is a high-level API
@@ -222,10 +224,14 @@ To delete items, use the Deleting Tables
---------------
+
+.. WARNING::
+ Deleting a table will also **permanently** delete all of its contents without prompt. Use carefully.
+
There are two easy ways to delete a table. Through your top-level
:py:class:`Layer2 <boto.dynamodb.layer2.Layer2>` object::
- >>> conn.delete_table('messages')
+ >>> conn.delete_table(table)
Or by getting the table, then using
:py:meth:`Table.delete <boto.dynamodb.table.Table.delete>`::
diff --git a/docs/source/index.rst b/docs/source/index.rst index 264d3d49..c669e446 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -24,7 +24,7 @@ Currently Supported Services * **Database** - * :doc:`SimpleDB <simpledb_tut>`-- (:doc:`API Reference <ref/sdb>`) + * :doc:`SimpleDB <simpledb_tut>` -- (:doc:`API Reference <ref/sdb>`) * :doc:`DynamoDB <dynamodb_tut>` -- (:doc:`API Reference <ref/dynamodb>`) * Relational Data Services (RDS) -- (:doc:`API Reference <ref/rds>`) @@ -40,7 +40,7 @@ Currently Supported Services * :doc:`Simple Queue Service (SQS) <sqs_tut>` -- (:doc:`API Reference <ref/sqs>`) * Simple Notification Service (SNS) -- (:doc:`API Reference <ref/sns>`) - * Simple Email Service (SES) -- (:doc:`API Reference <ref/ses>`) + * :doc:`Simple Email Service (SES) <ses_tut>` -- (:doc:`API Reference <ref/ses>`) * **Monitoring** @@ -103,6 +103,7 @@ Additional Resources sqs_tut ref/sqs ref/sns + ses_tut ref/ses cloudwatch_tut ref/cloudwatch diff --git a/docs/source/ses_tut.rst b/docs/source/ses_tut.rst new file mode 100644 index 00000000..c71e8868 --- /dev/null +++ b/docs/source/ses_tut.rst @@ -0,0 +1,171 @@ +.. ses_tut:
+
+=============================
+Simple Email Service Tutorial
+=============================
+
+This tutorial focuses on the boto interface to AWS' `Simple Email Service (SES) <ses>`_.
+This tutorial assumes that you have boto already downloaded and installed.
+
+.. _SES: http://aws.amazon.com/ses/
+
+Creating a Connection
+---------------------
+
+The first step in accessing SES is to create a connection to the service.
+To do so, the most straight forward way is the following::
+
+ >>> import boto
+ >>> conn = boto.connect_ses(
+ aws_access_key_id='<YOUR_AWS_KEY_ID>',
+ aws_secret_access_key='<YOUR_AWS_SECRET_KEY>')
+ >>> conn
+ SESConnection:email.us-east-1.amazonaws.com
+
+Bear in mind that if you have your credentials in boto config in your home
+directory, the two keyword arguments in the call above are not needed. More
+details on configuration can be fond in :doc:`boto_config_tut`.
+
+The :py:func:`boto.connect_ses` functions returns a
+:py:class:`boto.ses.connection.SESConnection` instance, which is a the boto API
+for working with SES.
+
+Notes on Sending
+----------------
+
+It is important to keep in mind that while emails appear to come "from" the
+address that you specify via Reply-To, the sending is done through Amazon.
+Some clients do pick up on this disparity, and leave a note on emails.
+
+Verifying a Sender Email Address
+--------------------------------
+
+Before you can send email "from" an address, you must prove that you have
+access to the account. When you send a validation request, an email is sent
+to the address with a link in it. Clicking on the link validates the address
+and adds it to your SES account. Here's how to send the validation email::
+
+ >>> conn.verify_email_address('some@address.com')
+ {
+ 'VerifyEmailAddressResponse': {
+ 'ResponseMetadata': {
+ 'RequestId': '4a974fd5-56c2-11e1-ad4c-c1f08c91d554'
+ }
+ }
+ }
+
+After a short amount of time, you'll find an email with the validation
+link inside. Click it, and this address may be used to send emails.
+
+Listing Verified Addresses
+--------------------------
+
+If you'd like to list the addresses that are currently verified on your
+SES account, use
+:py:meth:`list_verified_email_addresses <boto.ses.connection.SESConnection.list_verified_email_addresses>`::
+
+ >>> conn.list_verified_email_addresses()
+ {
+ 'ListVerifiedEmailAddressesResponse': {
+ 'ListVerifiedEmailAddressesResult': {
+ 'VerifiedEmailAddresses': [
+ 'some@address.com',
+ 'another@address.com'
+ ]
+ },
+ 'ResponseMetadata': {
+ 'RequestId': '2ab45c18-56c3-11e1-be66-ffd2a4549d70'
+ }
+ }
+ }
+
+Deleting a Verified Address
+---------------------------
+
+In the event that you'd like to remove an email address from your account,
+use
+:py:meth:`delete_verified_email_address <boto.ses.connection.SESConnection.delete_verified_email_address>`::
+
+ >>> conn.delete_verified_email_address('another@address.com')
+
+Sending an Email
+----------------
+
+Sending an email is done via
+:py:meth:`send_email <boto.ses.connection.SESConnection.send_email>`::
+
+ >>> conn.send_email(
+ 'some@address.com',
+ 'Your subject',
+ 'Body here',
+ ['recipient-address-1@gmail.com'])
+ {
+ 'SendEmailResponse': {
+ 'ResponseMetadata': {
+ 'RequestId': '4743c2b7-56c3-11e1-bccd-c99bd68002fd'
+ },
+ 'SendEmailResult': {
+ 'MessageId': '000001357a177192-7b894025-147a-4705-8455-7c880b0c8270-000000'
+ }
+ }
+ }
+
+If you're wanting to send a multipart MIME email, see the reference for
+:py:meth:`send_raw_email <boto.ses.connection.SESConnection.send_raw_email>`,
+which is a bit more of a low-level alternative.
+
+Checking your Send Quota
+------------------------
+
+Staying within your quota is critical, since the upper limit is a hard cap.
+Once you have hit your quota, no further email may be sent until enough
+time elapses to where your 24 hour email count (rolling continuously) is
+within acceptable ranges. Use
+:py:meth:`get_send_quota <boto.ses.connection.SESConnection.get_send_quota>`::
+
+ >>> conn.get_send_quota()
+ {
+ 'GetSendQuotaResponse': {
+ 'GetSendQuotaResult': {
+ 'Max24HourSend': '100000.0',
+ 'SentLast24Hours': '181.0',
+ 'MaxSendRate': '28.0'
+ },
+ 'ResponseMetadata': {
+ 'RequestId': u'8a629245-56c4-11e1-9c53-9d5f4d2cc8d3'
+ }
+ }
+ }
+
+Checking your Send Statistics
+-----------------------------
+
+In order to fight spammers and ensure quality mail is being sent from SES,
+Amazon tracks bounces, rejections, and complaints. This is done via
+:py:meth:`get_send_statistics <boto.ses.connection.SESConnection.get_send_statistics>`.
+Please be warned that the output is extremely verbose, to the point
+where we'll just show a short excerpt here::
+
+ >>> conn.get_send_statistics()
+ {
+ 'GetSendStatisticsResponse': {
+ 'GetSendStatisticsResult': {
+ 'SendDataPoints': [
+ {
+ 'Complaints': '0',
+ 'Timestamp': '2012-02-13T05:02:00Z',
+ 'DeliveryAttempts': '8',
+ 'Bounces': '0',
+ 'Rejects': '0'
+ },
+ {
+ 'Complaints': '0',
+ 'Timestamp': '2012-02-13T05:17:00Z',
+ 'DeliveryAttempts': '12',
+ 'Bounces': '0',
+ 'Rejects': '0'
+ }
+ ]
+ }
+ }
+ }
\ No newline at end of file @@ -51,7 +51,7 @@ setup(name = "boto", "bin/bundle_image", "bin/pyami_sendmail", "bin/lss3", "bin/cq", "bin/route53", "bin/s3multiput", "bin/cwutil", "bin/instance_events", "bin/asadmin"], - url = "http://code.google.com/p/boto/", + url = "https://github.com/boto/boto/", packages = ["boto", "boto.sqs", "boto.s3", "boto.gs", "boto.file", "boto.ec2", "boto.ec2.cloudwatch", "boto.ec2.autoscale", "boto.ec2.elb", "boto.sdb", "boto.cacerts", @@ -75,6 +75,10 @@ setup(name = "boto", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", - "Topic :: Internet"], + "Topic :: Internet", + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 2.5", + "Programming Language :: Python :: 2.6", + "Programming Language :: Python :: 2.7"], **extra ) diff --git a/tests/dynamodb/test_layer2.py b/tests/dynamodb/test_layer2.py index cc608fe6..9324c8d1 100644 --- a/tests/dynamodb/test_layer2.py +++ b/tests/dynamodb/test_layer2.py @@ -29,6 +29,8 @@ import time import uuid from boto.dynamodb.exceptions import DynamoDBKeyNotFoundError from boto.dynamodb.layer2 import Layer2 +from boto.dynamodb.types import get_dynamodb_type +from boto.dynamodb.condition import * class DynamoDBLayer2Test (unittest.TestCase): @@ -55,9 +57,9 @@ class DynamoDBLayer2Test (unittest.TestCase): table = c.create_table(table_name, schema, read_units, write_units) assert table.name == table_name assert table.schema.hash_key_name == hash_key_name - assert table.schema.hash_key_type == c.get_dynamodb_type(hash_key_proto_value) + assert table.schema.hash_key_type == get_dynamodb_type(hash_key_proto_value) assert table.schema.range_key_name == range_key_name - assert table.schema.range_key_type == c.get_dynamodb_type(range_key_proto_value) + assert table.schema.range_key_type == get_dynamodb_type(range_key_proto_value) assert table.read_units == read_units assert table.write_units == write_units @@ -212,15 +214,13 @@ class DynamoDBLayer2Test (unittest.TestCase): table2_item1.put() # Try a few queries - items = table.query('Amazon DynamoDB', - {'DynamoDB': 'BEGINS_WITH'}) + items = table.query('Amazon DynamoDB', BEGINS_WITH('DynamoDB')) n = 0 for item in items: n += 1 assert n == 2 - items = table.query('Amazon DynamoDB', - {'DynamoDB': 'BEGINS_WITH'}, + items = table.query('Amazon DynamoDB', BEGINS_WITH('DynamoDB'), request_limit=1, max_results=1) n = 0 for item in items: @@ -234,6 +234,12 @@ class DynamoDBLayer2Test (unittest.TestCase): n += 1 assert n == 3 + items = table.scan({'Replies': GT(0)}) + n = 0 + for item in items: + n += 1 + assert n == 1 + # Test some integer and float attributes integer_value = 42 float_value = 345.678 @@ -280,13 +286,19 @@ class DynamoDBLayer2Test (unittest.TestCase): assert len(response['Responses'][table.name]['Items']) == 2 # Try queries - results = table.query('Amazon DynamoDB', - range_key_condition={'DynamoDB': 'BEGINS_WITH'}) + results = table.query('Amazon DynamoDB', BEGINS_WITH('DynamoDB')) n = 0 for item in results: n += 1 assert n == 2 + # Try scans + results = table.scan({'Tags': CONTAINS('table')}) + n = 0 + for item in results: + n += 1 + assert n == 2 + # Try to delete the item with the right Expected value expected = {'Views': 0} item1.delete(expected_value=expected) diff --git a/tests/s3/mock_storage_service.py b/tests/s3/mock_storage_service.py index 2b81f5b6..2bd77439 100644 --- a/tests/s3/mock_storage_service.py +++ b/tests/s3/mock_storage_service.py @@ -29,7 +29,9 @@ of the optional params (which we indicate with the constant "NOT_IMPL"). import copy import boto import base64 + from boto.utils import compute_md5 +from boto.s3.prefix import Prefix try: from hashlib import md5 @@ -67,6 +69,12 @@ class MockKey(object): self.last_modified = 'Wed, 06 Oct 2010 05:11:54 GMT' self.BufferSize = 8192 + def __repr__(self): + if self.bucket: + return '<MockKey: %s,%s>' % (self.bucket.name, self.name) + else: + return '<MockKey: %s>' % self.name + def get_contents_as_string(self, headers=NOT_IMPL, cb=NOT_IMPL, num_cb=NOT_IMPL, torrent=NOT_IMPL, @@ -114,10 +122,10 @@ class MockKey(object): self.size = len(s) self._handle_headers(headers) - def set_contents_from_filename(self, filename, headers=None, replace=NOT_IMPL, - cb=NOT_IMPL, num_cb=NOT_IMPL, - policy=NOT_IMPL, md5=NOT_IMPL, - res_upload_handler=NOT_IMPL): + def set_contents_from_filename(self, filename, headers=None, + replace=NOT_IMPL, cb=NOT_IMPL, + num_cb=NOT_IMPL, policy=NOT_IMPL, + md5=NOT_IMPL, res_upload_handler=NOT_IMPL): fp = open(filename, 'rb') self.set_contents_from_file(fp, headers, replace, cb, num_cb, policy, md5, res_upload_handler) @@ -174,9 +182,13 @@ class MockBucket(object): self.connection = connection self.logging = False + def __repr__(self): + return 'MockBucket: %s' % self.name + def copy_key(self, new_key_name, src_bucket_name, src_key_name, metadata=NOT_IMPL, src_version_id=NOT_IMPL, - storage_class=NOT_IMPL, preserve_acl=NOT_IMPL): + storage_class=NOT_IMPL, preserve_acl=NOT_IMPL, + encrypt_key=NOT_IMPL, headers=NOT_IMPL, query_args=NOT_IMPL): new_key = self.new_key(key_name=new_key_name) src_key = mock_connection.get_bucket( src_bucket_name).get_key(src_key_name) @@ -231,17 +243,29 @@ class MockBucket(object): return None return self.keys[key_name] - def list(self, prefix='', delimiter=NOT_IMPL, marker=NOT_IMPL, + def list(self, prefix='', delimiter='', marker=NOT_IMPL, headers=NOT_IMPL): + prefix = prefix or '' # Turn None into '' for prefix match. # Return list instead of using a generator so we don't get # 'dictionary changed size during iteration' error when performing # deletions while iterating (e.g., during test cleanup). result = [] + key_name_set = set() for k in self.keys.itervalues(): - if not prefix: - result.append(k) - elif k.name.startswith(prefix): - result.append(k) + if k.name.startswith(prefix): + k_name_past_prefix = k.name[len(prefix):] + if delimiter: + pos = k_name_past_prefix.find(delimiter) + else: + pos = -1 + if (pos != -1): + key_or_prefix = Prefix( + bucket=self, name=k.name[:len(prefix)+pos+1]) + else: + key_or_prefix = MockKey(bucket=self, name=k.name) + if key_or_prefix.name not in key_name_set: + key_name_set.add(key_or_prefix.name) + result.append(key_or_prefix) return result def set_acl(self, acl_or_str, key_name='', headers=NOT_IMPL, @@ -250,10 +274,10 @@ class MockBucket(object): # the get_acl call will just return that string name. if key_name: # Set ACL for the key. - self.acls[key_name] = acl_or_str + self.acls[key_name] = MockAcl(acl_or_str) else: # Set ACL for the bucket. - self.acls[self.name] = acl_or_str + self.acls[self.name] = MockAcl(acl_or_str) def set_def_acl(self, acl_or_str, key_name=NOT_IMPL, headers=NOT_IMPL, version_id=NOT_IMPL): @@ -313,6 +337,8 @@ mock_connection = MockConnection() class MockBucketStorageUri(object): + delim = '/' + def __init__(self, scheme, bucket_name=None, object_name=None, debug=NOT_IMPL, suppress_consec_slashes=NOT_IMPL): self.scheme = scheme @@ -395,10 +421,28 @@ class MockBucketStorageUri(object): return True def names_container(self): - return not self.object_name + return bool(not self.object_name) def names_singleton(self): - return self.object_name + return bool(self.object_name) + + def names_directory(self): + return False + + def names_provider(self): + return bool(not self.bucket_name) + + def names_bucket(self): + return self.names_container() + + def names_file(self): + return False + + def names_object(self): + return not self.names_container() + + def is_stream(self): + return False def new_key(self, validate=NOT_IMPL, headers=NOT_IMPL): bucket = self.get_bucket() diff --git a/tests/s3/test_key.py b/tests/s3/test_key.py new file mode 100644 index 00000000..c961b317 --- /dev/null +++ b/tests/s3/test_key.py @@ -0,0 +1,321 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2012 Mitch Garnaat http://garnaat.org/ +# All rights reserved. +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, dis- +# tribute, sublicense, and/or sell copies of the Software, and to permit +# persons to whom the Software is furnished to do so, subject to the fol- +# lowing conditions: +# +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABIL- +# ITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +# SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +""" +Some unit tests for S3 Key +""" + +import unittest +import time +import StringIO +from boto.s3.connection import S3Connection +from boto.s3.key import Key +from boto.exception import S3ResponseError + +class S3KeyTest (unittest.TestCase): + + def setUp(self): + self.conn = S3Connection() + self.bucket_name = 'keytest-%d' % int(time.time()) + self.bucket = self.conn.create_bucket(self.bucket_name) + + def tearDown(self): + for key in self.bucket: + key.delete() + self.bucket.delete() + + def test_set_contents_as_file(self): + content="01234567890123456789" + sfp = StringIO.StringIO(content) + + # fp is set at 0 for just opened (for read) files. + # set_contents should write full content to key. + k = self.bucket.new_key("k") + k.set_contents_from_file(sfp) + self.assertEqual(k.size, 20) + kn = self.bucket.new_key("k") + ks = kn.get_contents_as_string() + self.assertEqual(ks, content) + + # set fp to 5 and set contents. this should + # set "567890123456789" to the key + sfp.seek(5) + k = self.bucket.new_key("k") + k.set_contents_from_file(sfp) + self.assertEqual(k.size, 15) + kn = self.bucket.new_key("k") + ks = kn.get_contents_as_string() + self.assertEqual(ks, content[5:]) + + # set fp to 5 and only set 5 bytes. this should + # write the value "56789" to the key. + sfp.seek(5) + k = self.bucket.new_key("k") + k.set_contents_from_file(sfp, size=5) + self.assertEqual(k.size, 5) + self.assertEqual(sfp.tell(), 10) + kn = self.bucket.new_key("k") + ks = kn.get_contents_as_string() + self.assertEqual(ks, content[5:10]) + + def test_set_contents_with_md5(self): + content="01234567890123456789" + sfp = StringIO.StringIO(content) + + # fp is set at 0 for just opened (for read) files. + # set_contents should write full content to key. + k = self.bucket.new_key("k") + good_md5 = k.compute_md5(sfp) + k.set_contents_from_file(sfp, md5=good_md5) + kn = self.bucket.new_key("k") + ks = kn.get_contents_as_string() + self.assertEqual(ks, content) + + # set fp to 5 and only set 5 bytes. this should + # write the value "56789" to the key. + sfp.seek(5) + k = self.bucket.new_key("k") + good_md5 = k.compute_md5(sfp, size=5) + k.set_contents_from_file(sfp, size=5, md5=good_md5) + self.assertEqual(sfp.tell(), 10) + kn = self.bucket.new_key("k") + ks = kn.get_contents_as_string() + self.assertEqual(ks, content[5:10]) + + # let's try a wrong md5 by just altering it. + k = self.bucket.new_key("k") + sfp.seek(0) + hexdig,base64 = k.compute_md5(sfp) + bad_md5 = (hexdig, base64[3:]) + try: + k.set_contents_from_file(sfp, md5=bad_md5) + self.fail("should fail with bad md5") + except S3ResponseError: + pass + + def test_get_contents_with_md5(self): + content="01234567890123456789" + sfp = StringIO.StringIO(content) + + k = self.bucket.new_key("k") + k.set_contents_from_file(sfp) + kn = self.bucket.new_key("k") + s = kn.get_contents_as_string() + self.assertEqual(kn.md5, k.md5) + self.assertEqual(s, content) + + def test_file_callback(self): + def callback(wrote, total): + self.my_cb_cnt += 1 + self.assertNotEqual(wrote, self.my_cb_last, "called twice with same value") + self.my_cb_last = wrote + + # Zero bytes written => 1 call + self.my_cb_cnt = 0 + self.my_cb_last = None + k = self.bucket.new_key("k") + k.BufferSize = 2 + sfp = StringIO.StringIO("") + k.set_contents_from_file(sfp, cb=callback, num_cb=10) + self.assertEqual(self.my_cb_cnt, 1) + self.assertEqual(self.my_cb_last, 0) + sfp.close() + + # Read back zero bytes => 1 call + self.my_cb_cnt = 0 + self.my_cb_last = None + s = k.get_contents_as_string(cb=callback) + self.assertEqual(self.my_cb_cnt, 1) + self.assertEqual(self.my_cb_last, 0) + + content="01234567890123456789" + sfp = StringIO.StringIO(content) + + # expect 2 calls due start/finish + self.my_cb_cnt = 0 + self.my_cb_last = None + k = self.bucket.new_key("k") + k.set_contents_from_file(sfp, cb=callback, num_cb=10) + self.assertEqual(self.my_cb_cnt, 2) + self.assertEqual(self.my_cb_last, 20) + + # Read back all bytes => 2 calls + self.my_cb_cnt = 0 + self.my_cb_last = None + s = k.get_contents_as_string(cb=callback) + self.assertEqual(self.my_cb_cnt, 2) + self.assertEqual(self.my_cb_last, 20) + self.assertEqual(s, content) + + # rewind sfp and try upload again. -1 should call + # for every read/write so that should make 11 when bs=2 + sfp.seek(0) + self.my_cb_cnt = 0 + self.my_cb_last = None + k = self.bucket.new_key("k") + k.BufferSize = 2 + k.set_contents_from_file(sfp, cb=callback, num_cb=-1) + self.assertEqual(self.my_cb_cnt, 11) + self.assertEqual(self.my_cb_last, 20) + + # Read back all bytes => 11 calls + self.my_cb_cnt = 0 + self.my_cb_last = None + s = k.get_contents_as_string(cb=callback, num_cb=-1) + self.assertEqual(self.my_cb_cnt, 11) + self.assertEqual(self.my_cb_last, 20) + self.assertEqual(s, content) + + # no more than 1 times => 2 times + # last time always 20 bytes + sfp.seek(0) + self.my_cb_cnt = 0 + self.my_cb_last = None + k = self.bucket.new_key("k") + k.BufferSize = 2 + k.set_contents_from_file(sfp, cb=callback, num_cb=1) + self.assertTrue(self.my_cb_cnt <= 2) + self.assertEqual(self.my_cb_last, 20) + + # no more than 1 times => 2 times + self.my_cb_cnt = 0 + self.my_cb_last = None + s = k.get_contents_as_string(cb=callback, num_cb=1) + self.assertTrue(self.my_cb_cnt <= 2) + self.assertEqual(self.my_cb_last, 20) + self.assertEqual(s, content) + + # no more than 2 times + # last time always 20 bytes + sfp.seek(0) + self.my_cb_cnt = 0 + self.my_cb_last = None + k = self.bucket.new_key("k") + k.BufferSize = 2 + k.set_contents_from_file(sfp, cb=callback, num_cb=2) + self.assertTrue(self.my_cb_cnt <= 2) + self.assertEqual(self.my_cb_last, 20) + + # no more than 2 times + self.my_cb_cnt = 0 + self.my_cb_last = None + s = k.get_contents_as_string(cb=callback, num_cb=2) + self.assertTrue(self.my_cb_cnt <= 2) + self.assertEqual(self.my_cb_last, 20) + self.assertEqual(s, content) + + # no more than 3 times + # last time always 20 bytes + sfp.seek(0) + self.my_cb_cnt = 0 + self.my_cb_last = None + k = self.bucket.new_key("k") + k.BufferSize = 2 + k.set_contents_from_file(sfp, cb=callback, num_cb=3) + self.assertTrue(self.my_cb_cnt <= 3) + self.assertEqual(self.my_cb_last, 20) + + # no more than 3 times + self.my_cb_cnt = 0 + self.my_cb_last = None + s = k.get_contents_as_string(cb=callback, num_cb=3) + self.assertTrue(self.my_cb_cnt <= 3) + self.assertEqual(self.my_cb_last, 20) + self.assertEqual(s, content) + + # no more than 4 times + # last time always 20 bytes + sfp.seek(0) + self.my_cb_cnt = 0 + self.my_cb_last = None + k = self.bucket.new_key("k") + k.BufferSize = 2 + k.set_contents_from_file(sfp, cb=callback, num_cb=4) + self.assertTrue(self.my_cb_cnt <= 4) + self.assertEqual(self.my_cb_last, 20) + + # no more than 4 times + self.my_cb_cnt = 0 + self.my_cb_last = None + s = k.get_contents_as_string(cb=callback, num_cb=4) + self.assertTrue(self.my_cb_cnt <= 4) + self.assertEqual(self.my_cb_last, 20) + self.assertEqual(s, content) + + # no more than 6 times + # last time always 20 bytes + sfp.seek(0) + self.my_cb_cnt = 0 + self.my_cb_last = None + k = self.bucket.new_key("k") + k.BufferSize = 2 + k.set_contents_from_file(sfp, cb=callback, num_cb=6) + self.assertTrue(self.my_cb_cnt <= 6) + self.assertEqual(self.my_cb_last, 20) + + # no more than 6 times + self.my_cb_cnt = 0 + self.my_cb_last = None + s = k.get_contents_as_string(cb=callback, num_cb=6) + self.assertTrue(self.my_cb_cnt <= 6) + self.assertEqual(self.my_cb_last, 20) + self.assertEqual(s, content) + + # no more than 10 times + # last time always 20 bytes + sfp.seek(0) + self.my_cb_cnt = 0 + self.my_cb_last = None + k = self.bucket.new_key("k") + k.BufferSize = 2 + k.set_contents_from_file(sfp, cb=callback, num_cb=10) + self.assertTrue(self.my_cb_cnt <= 10) + self.assertEqual(self.my_cb_last, 20) + + # no more than 10 times + self.my_cb_cnt = 0 + self.my_cb_last = None + s = k.get_contents_as_string(cb=callback, num_cb=10) + self.assertTrue(self.my_cb_cnt <= 10) + self.assertEqual(self.my_cb_last, 20) + self.assertEqual(s, content) + + # no more than 1000 times + # last time always 20 bytes + sfp.seek(0) + self.my_cb_cnt = 0 + self.my_cb_last = None + k = self.bucket.new_key("k") + k.BufferSize = 2 + k.set_contents_from_file(sfp, cb=callback, num_cb=1000) + self.assertTrue(self.my_cb_cnt <= 1000) + self.assertEqual(self.my_cb_last, 20) + + # no more than 1000 times + self.my_cb_cnt = 0 + self.my_cb_last = None + s = k.get_contents_as_string(cb=callback, num_cb=1000) + self.assertTrue(self.my_cb_cnt <= 1000) + self.assertEqual(self.my_cb_last, 20) + self.assertEqual(s, content) diff --git a/tests/s3/test_mfa.py b/tests/s3/test_mfa.py new file mode 100644 index 00000000..3f47e94c --- /dev/null +++ b/tests/s3/test_mfa.py @@ -0,0 +1,91 @@ +# Copyright (c) 2010 Mitch Garnaat http://garnaat.org/ +# Copyright (c) 2010, Eucalyptus Systems, Inc. +# All rights reserved. +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, dis- +# tribute, sublicense, and/or sell copies of the Software, and to permit +# persons to whom the Software is furnished to do so, subject to the fol- +# lowing conditions: +# +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABIL- +# ITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +# SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +""" +Some unit tests for S3 MfaDelete with versioning +""" + +import unittest +import time +from boto.s3.connection import S3Connection +from boto.exception import S3ResponseError +from boto.s3.deletemarker import DeleteMarker + +class S3MFATest (unittest.TestCase): + + def setUp(self): + self.conn = S3Connection() + self.bucket_name = 'mfa-%d' % int(time.time()) + self.bucket = self.conn.create_bucket(self.bucket_name) + + def tearDown(self): + for k in self.bucket.list_versions(): + self.bucket.delete_key(k.name, version_id=k.version_id) + self.bucket.delete() + + def test_mfadel(self): + # Enable Versioning with MfaDelete + mfa_sn = raw_input('MFA S/N: ') + mfa_code = raw_input('MFA Code: ') + self.bucket.configure_versioning(True, mfa_delete=True, mfa_token=(mfa_sn, mfa_code)) + + # Check enabling mfa worked. + i = 0 + for i in range(1,8): + time.sleep(2**i) + d = self.bucket.get_versioning_status() + if d['Versioning'] == 'Enabled' and d['MfaDelete'] == 'Enabled': + break + self.assertEqual('Enabled', d['Versioning']) + self.assertEqual('Enabled', d['MfaDelete']) + + # Add a key to the bucket + k = self.bucket.new_key('foobar') + s1 = 'This is v1' + k.set_contents_from_string(s1) + v1 = k.version_id + + # Now try to delete v1 without the MFA token + try: + self.bucket.delete_key('foobar', version_id=v1) + self.fail("Must fail if not using MFA token") + except S3ResponseError: + pass + + # Now try delete again with the MFA token + mfa_code = raw_input('MFA Code: ') + self.bucket.delete_key('foobar', version_id=v1, mfa_token=(mfa_sn, mfa_code)) + + # Next suspend versioning and disable MfaDelete on the bucket + mfa_code = raw_input('MFA Code: ') + self.bucket.configure_versioning(False, mfa_delete=False, mfa_token=(mfa_sn, mfa_code)) + + # Lastly, check disabling mfa worked. + i = 0 + for i in range(1,8): + time.sleep(2**i) + d = self.bucket.get_versioning_status() + if d['Versioning'] == 'Suspended' and d['MfaDelete'] != 'Enabled': + break + self.assertEqual('Suspended', d['Versioning']) + self.assertNotEqual('Enabled', d['MfaDelete']) diff --git a/tests/s3/test_multipart.py b/tests/s3/test_multipart.py index 5921889c..5c64ba73 100644 --- a/tests/s3/test_multipart.py +++ b/tests/s3/test_multipart.py @@ -90,3 +90,35 @@ class S3MultiPartUploadTest (unittest.TestCase): self.assertEqual(lmpu.key_name, key_name) # Abort using the one returned in the list lmpu.cancel_upload() + + def test_four_part_file(self): + key_name = "k" + contents = "01234567890123456789" + sfp = StringIO.StringIO(contents) + + # upload 20 bytes in 4 parts of 5 bytes each + mpu = self.bucket.initiate_multipart_upload(key_name) + mpu.upload_part_from_file(sfp, part_num=1, size=5) + mpu.upload_part_from_file(sfp, part_num=2, size=5) + mpu.upload_part_from_file(sfp, part_num=3, size=5) + mpu.upload_part_from_file(sfp, part_num=4, size=5) + sfp.close() + + etags = {} + pn = 0 + for part in mpu: + pn += 1 + self.assertEqual(5, part.size) + etags[pn] = part.etag + self.assertEqual(pn, 4) + # etags for 01234 + self.assertEqual(etags[1], etags[3]) + # etags for 56789 + self.assertEqual(etags[2], etags[4]) + # etag 01234 != etag 56789 + self.assertNotEqual(etags[1], etags[2]) + + # parts are too small to compete as each part must + # be a min of 5MB so so we'll assume that is enough + # testing and abort the upload. + mpu.cancel_upload() diff --git a/tests/s3/test_resumable_uploads.py b/tests/s3/test_resumable_uploads.py index bb0f7a93..8a4a51f3 100755 --- a/tests/s3/test_resumable_uploads.py +++ b/tests/s3/test_resumable_uploads.py @@ -202,6 +202,7 @@ class ResumableUploadTests(unittest.TestCase): """ Tests that non-resumable uploads work """ + self.small_src_file.seek(0) self.dst_key.set_contents_from_file(self.small_src_file) self.assertEqual(self.small_src_file_size, self.dst_key.size) self.assertEqual(self.small_src_file_as_string, @@ -212,6 +213,7 @@ class ResumableUploadTests(unittest.TestCase): Tests a single resumable upload, with no tracker URI persistence """ res_upload_handler = ResumableUploadHandler() + self.small_src_file.seek(0) self.dst_key.set_contents_from_file( self.small_src_file, res_upload_handler=res_upload_handler) self.assertEqual(self.small_src_file_size, self.dst_key.size) @@ -225,6 +227,7 @@ class ResumableUploadTests(unittest.TestCase): harnass = CallbackTestHarnass() res_upload_handler = ResumableUploadHandler( tracker_file_name=self.tracker_file_name, num_retries=0) + self.small_src_file.seek(0) try: self.dst_key.set_contents_from_file( self.small_src_file, cb=harnass.call, @@ -251,6 +254,7 @@ class ResumableUploadTests(unittest.TestCase): exception = ResumableUploadHandler.RETRYABLE_EXCEPTIONS[0] harnass = CallbackTestHarnass(exception=exception) res_upload_handler = ResumableUploadHandler(num_retries=1) + self.small_src_file.seek(0) self.dst_key.set_contents_from_file( self.small_src_file, cb=harnass.call, res_upload_handler=res_upload_handler) @@ -266,6 +270,7 @@ class ResumableUploadTests(unittest.TestCase): exception = IOError(errno.EPIPE, "Broken pipe") harnass = CallbackTestHarnass(exception=exception) res_upload_handler = ResumableUploadHandler(num_retries=1) + self.small_src_file.seek(0) self.dst_key.set_contents_from_file( self.small_src_file, cb=harnass.call, res_upload_handler=res_upload_handler) @@ -281,6 +286,7 @@ class ResumableUploadTests(unittest.TestCase): harnass = CallbackTestHarnass( exception=OSError(errno.EACCES, 'Permission denied')) res_upload_handler = ResumableUploadHandler(num_retries=1) + self.small_src_file.seek(0) try: self.dst_key.set_contents_from_file( self.small_src_file, cb=harnass.call, @@ -298,6 +304,7 @@ class ResumableUploadTests(unittest.TestCase): harnass = CallbackTestHarnass() res_upload_handler = ResumableUploadHandler( tracker_file_name=self.tracker_file_name, num_retries=1) + self.small_src_file.seek(0) self.dst_key.set_contents_from_file( self.small_src_file, cb=harnass.call, res_upload_handler=res_upload_handler) @@ -313,6 +320,7 @@ class ResumableUploadTests(unittest.TestCase): Tests resumable upload that fails twice in one process, then completes """ res_upload_handler = ResumableUploadHandler(num_retries=3) + self.small_src_file.seek(0) self.dst_key.set_contents_from_file( self.small_src_file, res_upload_handler=res_upload_handler) # Ensure uploaded object has correct content. @@ -332,6 +340,7 @@ class ResumableUploadTests(unittest.TestCase): fail_after_n_bytes=self.larger_src_file_size/2, num_times_to_fail=2) res_upload_handler = ResumableUploadHandler( tracker_file_name=self.tracker_file_name, num_retries=1) + self.larger_src_file.seek(0) try: self.dst_key.set_contents_from_file( self.larger_src_file, cb=harnass.call, @@ -343,6 +352,7 @@ class ResumableUploadTests(unittest.TestCase): # Ensure a tracker file survived. self.assertTrue(os.path.exists(self.tracker_file_name)) # Try it one more time; this time should succeed. + self.larger_src_file.seek(0) self.dst_key.set_contents_from_file( self.larger_src_file, cb=harnass.call, res_upload_handler=res_upload_handler) @@ -365,6 +375,7 @@ class ResumableUploadTests(unittest.TestCase): harnass = CallbackTestHarnass( fail_after_n_bytes=self.larger_src_file_size/2) res_upload_handler = ResumableUploadHandler(num_retries=1) + self.larger_src_file.seek(0) self.dst_key.set_contents_from_file( self.larger_src_file, cb=harnass.call, res_upload_handler=res_upload_handler) @@ -382,6 +393,7 @@ class ResumableUploadTests(unittest.TestCase): Tests uploading an empty file (exercises boundary conditions). """ res_upload_handler = ResumableUploadHandler() + self.empty_src_file.seek(0) self.dst_key.set_contents_from_file( self.empty_src_file, res_upload_handler=res_upload_handler) self.assertEqual(0, self.dst_key.size) @@ -393,6 +405,7 @@ class ResumableUploadTests(unittest.TestCase): res_upload_handler = ResumableUploadHandler() headers = {'Content-Type' : 'text/plain', 'Content-Encoding' : 'gzip', 'x-goog-meta-abc' : 'my meta', 'x-goog-acl' : 'public-read'} + self.small_src_file.seek(0) self.dst_key.set_contents_from_file( self.small_src_file, headers=headers, res_upload_handler=res_upload_handler) @@ -423,6 +436,7 @@ class ResumableUploadTests(unittest.TestCase): # upload server). res_upload_handler = ResumableUploadHandler( tracker_file_name=self.tracker_file_name, num_retries=0) + self.larger_src_file.seek(0) try: self.dst_key.set_contents_from_file( self.larger_src_file, cb=harnass.call, @@ -440,6 +454,7 @@ class ResumableUploadTests(unittest.TestCase): # 500 response in the next attempt. time.sleep(1) try: + self.largest_src_file.seek(0) self.dst_key.set_contents_from_file( self.largest_src_file, res_upload_handler=res_upload_handler) self.fail('Did not get expected ResumableUploadException') @@ -510,6 +525,7 @@ class ResumableUploadTests(unittest.TestCase): to set the content length when gzipping a file. """ res_upload_handler = ResumableUploadHandler() + self.small_src_file.seek(0) try: self.dst_key.set_contents_from_file( self.small_src_file, res_upload_handler=res_upload_handler, @@ -528,6 +544,7 @@ class ResumableUploadTests(unittest.TestCase): tracker_file_name=self.syntactically_invalid_tracker_file_name) # An error should be printed about the invalid URI, but then it # should run the update successfully. + self.small_src_file.seek(0) self.dst_key.set_contents_from_file( self.small_src_file, res_upload_handler=res_upload_handler) self.assertEqual(self.small_src_file_size, self.dst_key.size) @@ -542,6 +559,7 @@ class ResumableUploadTests(unittest.TestCase): tracker_file_name=self.invalid_upload_id_tracker_file_name) # An error should occur, but then the tracker URI should be # regenerated and the the update should succeed. + self.small_src_file.seek(0) self.dst_key.set_contents_from_file( self.small_src_file, res_upload_handler=res_upload_handler) self.assertEqual(self.small_src_file_size, self.dst_key.size) diff --git a/tests/s3/test_versioning.py b/tests/s3/test_versioning.py index 879e36b4..2d569af9 100644 --- a/tests/s3/test_versioning.py +++ b/tests/s3/test_versioning.py @@ -22,7 +22,7 @@ # IN THE SOFTWARE. """ -Some unit tests for the S3 Versioning and MfaDelete +Some unit tests for the S3 Versioning. """ import unittest @@ -33,31 +33,30 @@ from boto.s3.deletemarker import DeleteMarker class S3VersionTest (unittest.TestCase): + def setUp(self): + self.conn = S3Connection() + self.bucket_name = 'version-%d' % int(time.time()) + self.bucket = self.conn.create_bucket(self.bucket_name) + + def tearDown(self): + for k in self.bucket.list_versions(): + self.bucket.delete_key(k.name, version_id=k.version_id) + self.bucket.delete() + def test_1_versions(self): - print '--- running S3Version tests ---' - c = S3Connection() - # create a new, empty bucket - bucket_name = 'version-%d' % int(time.time()) - bucket = c.create_bucket(bucket_name) - - # now try a get_bucket call and see if it's really there - bucket = c.get_bucket(bucket_name) - - # enable versions - d = bucket.get_versioning_status() - assert not d.has_key('Versioning') - bucket.configure_versioning(versioning=True) - time.sleep(15) - d = bucket.get_versioning_status() - assert d['Versioning'] == 'Enabled' + # check versioning off + d = self.bucket.get_versioning_status() + self.assertFalse(d.has_key('Versioning')) + + # enable versioning + self.bucket.configure_versioning(versioning=True) + d = self.bucket.get_versioning_status() + self.assertEqual('Enabled', d['Versioning']) # create a new key in the versioned bucket - k = bucket.new_key() - k.name = 'foobar' - s1 = 'This is a test of s3 versioning' - s2 = 'This is the second test of s3 versioning' + k = self.bucket.new_key("foobar") + s1 = 'This is v1' k.set_contents_from_string(s1) - time.sleep(5) # remember the version id of this object v1 = k.version_id @@ -65,109 +64,77 @@ class S3VersionTest (unittest.TestCase): # now get the contents from s3 o1 = k.get_contents_as_string() - # check to make sure content read from s3 is identical to original - assert o1 == s1 + # check to make sure content read from k is identical to original + self.assertEqual(s1, o1) # now overwrite that same key with new data + s2 = 'This is v2' k.set_contents_from_string(s2) v2 = k.version_id - time.sleep(5) - # now retrieve the contents as a string and compare - s3 = k.get_contents_as_string(version_id=v2) - assert s3 == s2 + # now retrieve latest contents as a string and compare + k2 = self.bucket.new_key("foobar") + o2 = k2.get_contents_as_string() + self.assertEqual(s2, o2) + + # next retrieve explicit versions and compare + o1 = k.get_contents_as_string(version_id=v1) + o2 = k.get_contents_as_string(version_id=v2) + self.assertEqual(s1, o1) + self.assertEqual(s2, o2) # Now list all versions and compare to what we have - rs = bucket.get_all_versions() - assert rs[0].version_id == v2 - assert rs[1].version_id == v1 + rs = self.bucket.get_all_versions() + self.assertEqual(v2, rs[0].version_id) + self.assertEqual(v1, rs[1].version_id) # Now do a regular list command and make sure only the new key shows up - rs = bucket.get_all_keys() - assert len(rs) == 1 + rs = self.bucket.get_all_keys() + self.assertEqual(1, len(rs)) # Now do regular delete - bucket.delete_key('foobar') - time.sleep(5) + self.bucket.delete_key('foobar') # Now list versions and make sure old versions are there - # plus the DeleteMarker - rs = bucket.get_all_versions() - assert len(rs) == 3 - assert isinstance(rs[0], DeleteMarker) + # plus the DeleteMarker which is latest. + rs = self.bucket.get_all_versions() + self.assertEqual(3, len(rs)) + self.assertTrue(isinstance(rs[0], DeleteMarker)) # Now delete v1 of the key - bucket.delete_key('foobar', version_id=v1) - time.sleep(5) + self.bucket.delete_key('foobar', version_id=v1) # Now list versions again and make sure v1 is not there - rs = bucket.get_all_versions() + rs = self.bucket.get_all_versions() versions = [k.version_id for k in rs] - assert v1 not in versions - assert v2 in versions - - # Now try to enable MfaDelete - mfa_sn = raw_input('MFA S/N: ') - mfa_code = raw_input('MFA Code: ') - bucket.configure_versioning(True, mfa_delete=True, mfa_token=(mfa_sn, mfa_code)) - i = 0 - for i in range(1,8): - time.sleep(2**i) - d = bucket.get_versioning_status() - if d['Versioning'] == 'Enabled' and d['MfaDelete'] == 'Enabled': - break - assert d['Versioning'] == 'Enabled' - assert d['MfaDelete'] == 'Enabled' - - # Now try to delete v2 without the MFA token - try: - bucket.delete_key('foobar', version_id=v2) - except S3ResponseError: - pass - - # Now try to delete v2 with the MFA token - mfa_code = raw_input('MFA Code: ') - bucket.delete_key('foobar', version_id=v2, mfa_token=(mfa_sn, mfa_code)) - - # Now disable MfaDelete on the bucket - mfa_code = raw_input('MFA Code: ') - bucket.configure_versioning(True, mfa_delete=False, mfa_token=(mfa_sn, mfa_code)) - + self.assertTrue(v1 not in versions) + self.assertTrue(v2 in versions) + # Now suspend Versioning on the bucket - bucket.configure_versioning(False) + self.bucket.configure_versioning(False) + d = self.bucket.get_versioning_status() + self.assertEqual('Suspended', d['Versioning']) - # now delete all keys and deletemarkers in bucket - for k in bucket.list_versions(): - bucket.delete_key(k.name, version_id=k.version_id) - - # now delete bucket - c.delete_bucket(bucket) - print '--- tests completed ---' - def test_latest_version(self): - c = S3Connection() - bucket_name = 'version-%d' % int(time.time()) - bucket = c.create_bucket(bucket_name) - - bucket.configure_versioning(versioning=True) + self.bucket.configure_versioning(versioning=True) # add v1 of an object key_name = "key" - kv1 = bucket.new_key(key_name) + kv1 = self.bucket.new_key(key_name) kv1.set_contents_from_string("v1") # read list which should contain latest v1 - listed_kv1 = iter(bucket.get_all_versions()).next() + listed_kv1 = iter(self.bucket.get_all_versions()).next() self.assertEqual(listed_kv1.name, key_name) self.assertEqual(listed_kv1.version_id, kv1.version_id) self.assertEqual(listed_kv1.is_latest, True) # add v2 of the object - kv2 = bucket.new_key(key_name) + kv2 = self.bucket.new_key(key_name) kv2.set_contents_from_string("v2") # read 2 versions, confirm v2 is latest - i = iter(bucket.get_all_versions()) + i = iter(self.bucket.get_all_versions()) listed_kv2 = i.next() listed_kv1 = i.next() self.assertEqual(listed_kv2.version_id, kv2.version_id) @@ -176,8 +143,8 @@ class S3VersionTest (unittest.TestCase): self.assertEqual(listed_kv1.is_latest, False) # delete key, which creates a delete marker as latest - bucket.delete_key(key_name) - i = iter(bucket.get_all_versions()) + self.bucket.delete_key(key_name) + i = iter(self.bucket.get_all_versions()) listed_kv3 = i.next() listed_kv2 = i.next() listed_kv1 = i.next() @@ -187,8 +154,3 @@ class S3VersionTest (unittest.TestCase): self.assertEqual(listed_kv3.is_latest, True) self.assertEqual(listed_kv2.is_latest, False) self.assertEqual(listed_kv1.is_latest, False) - - # cleanup - for k in bucket.list_versions(): - bucket.delete_key(k.name, version_id=k.version_id) - c.delete_bucket(bucket) diff --git a/tests/test.py b/tests/test.py index bc6cd756..7f423b71 100755 --- a/tests/test.py +++ b/tests/test.py @@ -32,8 +32,10 @@ import getopt from sqs.test_connection import SQSConnectionTest from s3.test_connection import S3ConnectionTest from s3.test_versioning import S3VersionTest +from s3.test_mfa import S3MFATest from s3.test_encryption import S3EncryptionTest from s3.test_bucket import S3BucketTest +from s3.test_key import S3KeyTest from s3.test_multidelete import S3MultiDeleteTest from s3.test_multipart import S3MultiPartUploadTest from s3.test_gsconnection import GSConnectionTest @@ -48,7 +50,7 @@ from sts.test_session_token import SessionTokenTest def usage(): print "test.py [-t testsuite] [-v verbosity]" - print " -t run specific testsuite (s3|ssl|s3ver|s3nover|gs|sqs|ec2|sdb|dynamodb|dynamodbL1|dynamodbL2|sts|all)" + print " -t run specific testsuite (s3|ssl|s3mfa|gs|sqs|ec2|sdb|dynamodb|dynamodbL1|dynamodbL2|sts|all)" print " -v verbosity (0|1|2)" def main(): @@ -93,21 +95,16 @@ def suite(testsuite="all"): tests.addTest(unittest.makeSuite(DynamoDBLayer2Test)) elif testsuite == "s3": tests.addTest(unittest.makeSuite(S3ConnectionTest)) + tests.addTest(unittest.makeSuite(S3BucketTest)) + tests.addTest(unittest.makeSuite(S3KeyTest)) + tests.addTest(unittest.makeSuite(S3MultiPartUploadTest)) tests.addTest(unittest.makeSuite(S3VersionTest)) tests.addTest(unittest.makeSuite(S3EncryptionTest)) tests.addTest(unittest.makeSuite(S3MultiDeleteTest)) - tests.addTest(unittest.makeSuite(S3MultiPartUploadTest)) - tests.addTest(unittest.makeSuite(S3BucketTest)) elif testsuite == "ssl": tests.addTest(unittest.makeSuite(CertValidationTest)) - elif testsuite == "s3ver": - tests.addTest(unittest.makeSuite(S3VersionTest)) - elif testsuite == "s3nover": - tests.addTest(unittest.makeSuite(S3ConnectionTest)) - tests.addTest(unittest.makeSuite(S3EncryptionTest)) - tests.addTest(unittest.makeSuite(S3MultiDeleteTest)) - tests.addTest(unittest.makeSuite(S3MultiPartUploadTest)) - tests.addTest(unittest.makeSuite(S3BucketTest)) + elif testsuite == "s3mfa": + tests.addTest(unittest.makeSuite(S3MFATest)) elif testsuite == "gs": tests.addTest(unittest.makeSuite(GSConnectionTest)) elif testsuite == "sqs": |