Jonathan Jacobs has proposed merging lp:~txaws-dev/txaws/415691-add-simpledb into lp:txaws.
Requested reviews: txAWS Developers (txaws-dev) Related bugs: Bug #415691 in txAWS: "Add SimpleDB support" https://bugs.launchpad.net/txaws/+bug/415691 For more details, see: https://code.launchpad.net/~txaws-dev/txaws/415691-add-simpledb/+merge/88530 -- https://code.launchpad.net/~txaws-dev/txaws/415691-add-simpledb/+merge/88530 Your team txAWS Developers is requested to review the proposed merge of lp:~txaws-dev/txaws/415691-add-simpledb into lp:txaws.
=== added file 'LICENSE' --- LICENSE 1970-01-01 00:00:00 +0000 +++ LICENSE 2012-01-13 16:49:25 +0000 @@ -0,0 +1,23 @@ +Copyright (C) 2008 Tristan Seligmann <[email protected]> +Copyright (C) 2009 Robert Collins <[email protected]> +Copyright (C) 2009 Canonical Ltd +Copyright (C) 2009 Duncan McGreggor <[email protected]> + +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, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following 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 +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 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. === removed file 'LICENSE' --- LICENSE 2009-11-22 02:20:42 +0000 +++ LICENSE 1970-01-01 00:00:00 +0000 @@ -1,23 +0,0 @@ -Copyright (C) 2008 Tristan Seligmann <[email protected]> -Copyright (C) 2009 Robert Collins <[email protected]> -Copyright (C) 2009 Canonical Ltd -Copyright (C) 2009 Duncan McGreggor <[email protected]> - -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, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following 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 -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 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. === added directory 'txaws/db' === added file 'txaws/db/__init__.py' === added file 'txaws/db/client.py' --- txaws/db/client.py 1970-01-01 00:00:00 +0000 +++ txaws/db/client.py 2012-01-13 16:49:25 +0000 @@ -0,0 +1,373 @@ +""" +Amazon SimpleDB client. + +@var exists: Attribute condition callable, taking a value, indicating the + attribute should exist with the specified value. +@var does_not_exist: Attribute condition callable, indicating the attribute + should I{not} exist. +@var replace: A value decorator for specifying that the value is a replacement. + +@see: U{http://docs.amazonwebservices.com/AmazonSimpleDB/latest/DeveloperGuide/SDB_API.html} +""" +import re +from decimal import Decimal +from functools import partial + +from txaws import version +from txaws.client.base import BaseClient, error_wrapper +from txaws.db.exception import SimpleDBError +from txaws.db.response import getResponseType +from txaws.ec2 import client as ec2client +from txaws.util import XML + + + +class paramdict(dict): + """ + Marker type. + """ + + + +# Convenience constructors +replace = lambda value: [paramdict({'Value': value, 'Replace': 'true'})] +exists = lambda value: [paramdict({'Value': value, 'Exists': 'true'})] +does_not_exist = lambda: [paramdict({'Exists': 'false'})] + + + +def _normalize(d): + """ + Normalize a C{dict} to a list of L{paramdict}s. + """ + for (name, values) in d.items(): + for value in values: + if not isinstance(value, paramdict): + value = paramdict({'Value': value}) + value['Name'] = name + yield value + + + +def _flatten(prefix, paramdicts): + """ + Flatten and enumerate a list of L{paramdict}s with the given prefix. + """ + for (n, subdict) in enumerate(paramdicts, 1): + for (key, value) in subdict.items(): + yield '%s.%d.%s' % (prefix, n, key), value + + + +def _attributes_to_parameters(attributes, conditions=None): + """ + Convert attribute name/values and conditions to a parameter dictionary. + + @type attributes: C{dict} mapping C{str} to C{list} or L{paramdict} + @param attributes: Mapping of attribute names to either C{list}s of C{str} + values or L{paramdict} instances, such as L{replace}. + + @type conditions: C{dict} mapping C{str} to L{paramdict} + @param conditions: Mapping of attribute names to either C{str} values, + to indicate expected values, or L{paramdict} instances, such as + L{exists} or L{does_not_exist}. + """ + if conditions is None: + conditions = {} + params = {} + params.update(_flatten('Attribute', _normalize(attributes))) + params.update(_flatten('Expected', _normalize(conditions))) + return params + + + +def interpolate_select(s, values): + """ + Interpolate attribute or domain names and values into a I{SELECT} query. + + Attribute or domain names are denoted with a hash (I{#}) and values with + a question mark (I{?}). + + @raise ValueError: If not enough values were provided. + + @return: Interpolated string. + """ + def _sub(vs, m): + f = [quote_name, quote_value][m.group(1) == '?'] + try: + return f(vs.next()) + except StopIteration: + raise ValueError('Too few values') + return re.sub(r'(\?|#)', partial(_sub, iter(values)), s) + + + +def quote_name(s): + """ + Quote an attribute or domain name with backticks (I{`}) and escape + backticks in the name. + """ + return '`%s`' % (s.replace('`', '``'),) + + + +def quote_value(s): + """ + Quote a value with single-quotes (I{'}) and escape single-quotes in the + value. + """ + return "'%s'" % (s.replace("'", "''"),) + + + +class SimpleDBClient(BaseClient): + """ + Simple DB client. + + @type total_box_usage: C{Decimal} + @ivar total_box_usage: Total box usage for this client's session. + """ + def __init__(self, creds=None, endpoint=None, query_factory=None): + if query_factory is None: + query_factory = Query + super(SimpleDBClient, self).__init__(creds, endpoint, query_factory) + self.total_box_usage = Decimal(0) + + + def _handle_response(self, response): + """ + Parse and process a successful response. + """ + tree = XML(response) + response = getResponseType(tree.tag)(tree) + self.total_box_usage += response.box_usage + return response + + + def _handle_error(self, f): + response = getattr(f.value, 'response', None) + if response is not None: + self.total_box_usage += f.value.response.box_usage + return f + + + def _submit(self, action, params): + """ + Submit a request to the endpoint and handle the response. + """ + query = self.query_factory( + action=action, + creds=self.creds, + endpoint=self.endpoint, + other_params=params) + d = query.submit() + return d.addCallbacks(self._handle_response, self._handle_error) + + + def create_domain(self, domain_name): + """ + Create a new domain. + + The domain name must be unique among the domains associated with the + AWS access key provided. + + @type domain_name: C{str} + @param domain_name: Name of the domain in which to perform the + operation. + """ + return self._submit('CreateDomain', {'DomainName': domain_name}) + + + def delete_domain(self, domain_name): + """ + Delete an existing domain. + + Any items, and their attributes, in the domain are deleted as well. + + @type domain_name: C{str} + @param domain_name: Name of the domain in which to perform the + operation. + """ + return self._submit('DeleteDomain', {'DomainName': domain_name}) + + + def list_domains(self, max_num_domains=100, next_token=None): + """ + List all domains associated with the AWS access key. + + @type max_num_domains: C{int} + @param max_num_domains: Maximum number of domains to list, a token + is returned if there are more than C{max_num_domains} to return, + calling this method successively with the token passed to + C{next_token} will return more results each time. + + @param next_token: Token, from a previous response, to retrieve + additional results from a previous query. + """ + params = {} + if max_num_domains: + params['MaxNumberOfDomains'] = str(max_num_domains) + if next_token: + params['NextToken'] = next_token + return self._submit('ListDomains', params) + + + def domain_metadata(self, domain_name): + """ + Get information about the domain, including when the domain was + created, the number of items and attributes, and the size of attribute + names and values. + + @type domain_name: C{str} + @param domain_name: Name of the domain in which to perform the + operation. + """ + return self._submit('DomainMetadata', {'DomainName': domain_name}) + + + def put_attributes(self, domain_name, item_name, attributes, + conditions=None): + """ + Create or replace attributes on an item. + + Attributes are uniquely identified in an item by their name/value + combination. + + @type domain_name: C{str} + @param domain_name: Name of the domain in which to perform the + operation. + + @type item_name: C{str} + @param item_name: Name of the item. + + @type attributes: C{dict} mapping C{str} to C{list} or L{paramdict} + @param attributes: Mapping of attribute names to either C{list}s of + C{str} values or L{paramdict} instances, such as L{replace}. + + @type conditions: C{dict} mapping C{str} to L{paramdict} + @param conditions: Mapping of attribute names to either C{str} values, + to indicate expected values, or L{paramdict} instances, such as + L{exists} or L{does_not_exist}. + """ + params = { + 'DomainName': domain_name, + 'ItemName': item_name} + params.update( + _attributes_to_parameters(attributes, conditions)) + return self._submit('PutAttributes', params) + + + def delete_attributes(self, domain_name, item_name, attributes, + conditions=None): + """ + Deletes one or more attributes associated with the item. + + If all attributes of an item are deleted, the item is deleted. + + @type domain_name: C{str} + @param domain_name: Name of the domain in which to perform the + operation. + + @type item_name: C{str} + @param item_name: Name of the item. + + @type attributes: C{dict} mapping C{str} to C{list} or L{paramdict} + @param attributes: Mapping of attribute names to either C{list}s of + C{str} values or L{paramdict} instances, such as L{replace}. If no + attributes are specified, all the attributes for the item are + deleted. + + @type conditions: C{dict} mapping C{str} to L{paramdict} + @param conditions: Mapping of attribute names to either C{str} values, + to indicate expected values, or L{paramdict} instances, such as + L{exists} or L{does_not_exist}. + """ + if attributes is None: + attributes = {} + params = { + 'DomainName': domain_name, + 'ItemName': item_name} + params.update( + _attributes_to_parameters(attributes, conditions)) + return self._submit('DeleteAttributes', params) + + + def get_attributes(self, domain_name, item_name, attribute_names=None, + consistent_read=False): + """ + Get attributes associated with an item. + + @type domain_name: C{str} + @param domain_name: Name of the domain in which to perform the + operation. + + @type item_name: C{str} + @param item_name: Name of the item. + + @type attribute_names: C{list} of C{str} + @param attribute_names: Attribute names to retrieve, or C{None} to + retrieve all attributes. + + @type consistent_read: C{bool} + @param consistent_read: Ensure that the most recent data is returned? + + @see: U{http://docs.amazonwebservices.com/AmazonSimpleDB/latest/DeveloperGuide/ConsistencySummary.html} + """ + if attribute_names is None: + attribute_names = [] + params = { + 'DomainName': domain_name, + 'ItemName': item_name, + 'ConsistentRead': ['false', 'true'][consistent_read]} + for n, value in enumerate(attribute_names, 1): + params['AttributeName.%d' % (n,)] = value + return self._submit('GetAttributes', params) + + + def select(self, expn, values=None, consistent_read=False, + next_token=None): + """ + Perform a Simple DB query. + + @type expn: C{str} + @param expn: SimpleDB select expression. + + @type values: C{tuple} + @param values: Values to interpolate into L{expn}, C{'#'} is the + interpolation character for attribute and domain names while C{'?'} + is the interpolation character for values, or C{None} if no + interpolation is required. + + @type consistent_read: C{bool} + @param consistent_read: Ensure that the most recent data is returned? + + @param next_token: Token, from a previous response, to retrieve + additional results from a previous query. + + @see: U{http://docs.amazonwebservices.com/AmazonSimpleDB/latest/DeveloperGuide/UsingSelect.html} + """ + if values is not None: + expn = interpolate_select(expn, values) + params = { + 'SelectExpression': expn, + 'ConsistentRead': ['false', 'true'][consistent_read]} + if next_token: + params['NextToken'] = next_token + return self._submit('Select', params) + + + +class Query(ec2client.Query): + """ + A query that may be submitted to SimpleDB. + """ + def __init__(self, other_params=None, time_tuple=None, api_version=None, + *args, **kwargs): + if api_version is None: + api_version = version.sdb_api + super(Query, self).__init__( + other_params, time_tuple, api_version, *args, **kwargs) + + + def _handle_error(self, f): + return error_wrapper(f, SimpleDBError) === added file 'txaws/db/exception.py' --- txaws/db/exception.py 1970-01-01 00:00:00 +0000 +++ txaws/db/exception.py 2012-01-13 16:49:25 +0000 @@ -0,0 +1,21 @@ +from txaws.exception import AWSError +from txaws.db.response import ErrorResponse + + + +class SimpleDBError(AWSError): + """ + A error class providing custom methods on SimpleDB errors. + """ + def _set_request_id(self, tree): + super(SimpleDBError, self)._set_request_id(tree) + self.response = ErrorResponse(tree) + + + def _set_400_error(self, tree): + errors_node = tree.find(".//Errors") + if errors_node is not None: + for error in errors_node: + data = self._node_to_dict(error) + if data: + self.errors.append(data) === added file 'txaws/db/response.py' --- txaws/db/response.py 1970-01-01 00:00:00 +0000 +++ txaws/db/response.py 2012-01-13 16:49:25 +0000 @@ -0,0 +1,192 @@ +from decimal import Decimal + + + +class ErrorResponse(object): + """ + Error response. + """ + def __init__(self, tree): + self.request_id = tree.findtext('RequestID') + self.box_usage = Decimal(0) + self.errors = {} + for e in tree.findall('Errors/Error'): + code = e.findtext('Code') + message = e.findtext('Message') + box_usage = Decimal(e.findtext('BoxUsage')) + self.box_usage += box_usage + self.errors[code] = { + 'message': message, + 'box_usage': box_usage} + + + def __repr__(self): + return '<%s %s>' % ( + type(self).__name__, + ' '.join(_repr_attrs( + dict(errors=self.errors, + request_id=self.request_id).items()))) + + + +def _repr_attrs(items): + """ + Construct a C{list} of strings containing name-value pairs suitable for use + in C{__repr__}. + """ + return [ + '%s=%r' % (key, value) + for key, value in sorted(items) if value is not None] + + + +class BaseResponse(object): + """ + Base response. + + @ivar request_id: Unique request identifier. + @ivar box_usage: Billable machine utilization for the request. + """ + def __init__(self, tree): + self.request_id = tree.findtext( + 'ResponseMetadata/RequestId') + self.box_usage = Decimal(tree.findtext( + 'ResponseMetadata/BoxUsage')) + + + def __repr__(self): + return '<%s %s>' % ( + type(self).__name__, + ' '.join(_repr_attrs(vars(self).items()))) + + + +class CreateDomainResponse(BaseResponse): + """ + CreateDomain response. + """ + + + +class DeleteDomainResponse(BaseResponse): + """ + DeleteDomain response. + """ + + + +class ListDomainsResponse(BaseResponse): + """ + ListDomains response. + + @type domains: C{list} + @ivar domains: Domain names. + + @ivar next_token: Token used to retrieve the next page of results or + C{None} if there are no more results. + """ + def __init__(self, tree): + super(ListDomainsResponse, self).__init__(tree) + self.domains = [] + r = tree.find('ListDomainsResult') + for e in r.findall('DomainName'): + self.domains.append(e.text) + self.next_token = r.findtext('NextToken') + + + +class DomainMetadataResponse(BaseResponse): + """ + DomainMetadata response. + + @type metadata: C{dict} + @ivar metadata: Domain metadata. + """ + def __init__(self, tree): + super(DomainMetadataResponse, self).__init__(tree) + r = tree.find('DomainMetadataResult') + self.metadata = dict((e.tag, e.text) for e in r.getchildren()) + + + +class PutAttributesResponse(BaseResponse): + """ + PutAttributes response. + """ + + + +class DeleteAttributesResponse(BaseResponse): + """ + DeleteAttributes response. + """ + + + +def _get_attributes(tree): + """ + Extract all C{'Name'} / C{'Value'} element pairs from an element tree, + returning them in dictionary. + """ + attributes = {} + for e in tree.findall('Attribute'): + name = e.findtext('Name') + value = e.findtext('Value') + attributes.setdefault(name, []).append(value) + return attributes + + + +class GetAttributesResponse(BaseResponse): + """ + GetAttributes response. + + @type attributes: C{dict} + @ivar attributes: Item attributes. + """ + def __init__(self, tree): + super(GetAttributesResponse, self).__init__(tree) + self.attributes = _get_attributes(tree.find('GetAttributesResult')) + + + +class SelectResponse(BaseResponse): + """ + Select response. + + @type items: C{list} of C{(str, dict)} + @ivar items: Select results as a C{list} of C{(item_name, attributes)}. + + @ivar next_token: Token used to retrieve the next page of results or + C{None} if there are no more results. + """ + def __init__(self, tree): + super(SelectResponse, self).__init__(tree) + self.items = [] + r = tree.find('SelectResult') + for e in r.findall('Item'): + name = e.findtext('Name') + self.items.append((name, _get_attributes(e))) + self.next_token = r.findtext('NextToken') + + + +_responseObjects = { + 'CreateDomainResponse': CreateDomainResponse, + 'DeleteDomainResponse': DeleteDomainResponse, + 'ListDomainsResponse': ListDomainsResponse, + 'DomainMetadataResponse': DomainMetadataResponse, + 'PutAttributesResponse': PutAttributesResponse, + 'DeleteAttributesResponse': DeleteAttributesResponse, + 'GetAttributesResponse': GetAttributesResponse, + 'SelectResponse': SelectResponse, + 'Response': ErrorResponse} + +def getResponseType(name): + """ + Get the type responsible for handling responses named C{name}. + """ + t = _responseObjects.get(name) + if t is None: + raise ValueError('Unknown response type: %r' % (name,)) + return t === added directory 'txaws/db/tests' === added file 'txaws/db/tests/__init__.py' === added directory 'txaws/db/tests/data' === added file 'txaws/db/tests/data/create_domain.xml' --- txaws/db/tests/data/create_domain.xml 1970-01-01 00:00:00 +0000 +++ txaws/db/tests/data/create_domain.xml 2012-01-13 16:49:25 +0000 @@ -0,0 +1,7 @@ +<?xml version="1.0"?> +<CreateDomainResponse xmlns="http://sdb.amazonaws.com/doc/2009-04-15/"> + <ResponseMetadata> + <RequestId>6b1443da-e17d-7e4e-6590-c4fd0c660c43</RequestId> + <BoxUsage>0.0055590278</BoxUsage> + </ResponseMetadata> +</CreateDomainResponse> === added file 'txaws/db/tests/data/delete_attributes.xml' --- txaws/db/tests/data/delete_attributes.xml 1970-01-01 00:00:00 +0000 +++ txaws/db/tests/data/delete_attributes.xml 2012-01-13 16:49:25 +0000 @@ -0,0 +1,7 @@ +<?xml version="1.0"?> +<DeleteAttributesResponse xmlns="http://sdb.amazonaws.com/doc/2009-04-15/"> + <ResponseMetadata> + <RequestId>af885768-f978-5fa3-1845-cfa981f9c5c2</RequestId> + <BoxUsage>0.0000219907</BoxUsage> + </ResponseMetadata> +</DeleteAttributesResponse> === added file 'txaws/db/tests/data/delete_domain.xml' --- txaws/db/tests/data/delete_domain.xml 1970-01-01 00:00:00 +0000 +++ txaws/db/tests/data/delete_domain.xml 2012-01-13 16:49:25 +0000 @@ -0,0 +1,7 @@ +<?xml version="1.0"?> +<DeleteDomainResponse xmlns="http://sdb.amazonaws.com/doc/2009-04-15/"> + <ResponseMetadata> + <RequestId>2757f5be-80a4-94a9-a6ce-5fe192bb4587</RequestId> + <BoxUsage>0.0055590278</BoxUsage> + </ResponseMetadata> +</DeleteDomainResponse> === added file 'txaws/db/tests/data/domain_metadata.xml' --- txaws/db/tests/data/domain_metadata.xml 1970-01-01 00:00:00 +0000 +++ txaws/db/tests/data/domain_metadata.xml 2012-01-13 16:49:25 +0000 @@ -0,0 +1,16 @@ +<?xml version="1.0"?> +<DomainMetadataResponse xmlns="http://sdb.amazonaws.com/doc/2009-04-15/"> + <DomainMetadataResult> + <ItemCount>0</ItemCount> + <ItemNamesSizeBytes>0</ItemNamesSizeBytes> + <AttributeNameCount>0</AttributeNameCount> + <AttributeNamesSizeBytes>0</AttributeNamesSizeBytes> + <AttributeValueCount>0</AttributeValueCount> + <AttributeValuesSizeBytes>0</AttributeValuesSizeBytes> + <Timestamp>1325687708</Timestamp> + </DomainMetadataResult> + <ResponseMetadata> + <RequestId>c1ea9859-d869-2b80-6749-31c2681d463a</RequestId> + <BoxUsage>0.0000071759</BoxUsage> + </ResponseMetadata> +</DomainMetadataResponse> === added file 'txaws/db/tests/data/error.xml' --- txaws/db/tests/data/error.xml 1970-01-01 00:00:00 +0000 +++ txaws/db/tests/data/error.xml 2012-01-13 16:49:25 +0000 @@ -0,0 +1,11 @@ +<?xml version="1.0"?> +<Response> + <Errors> + <Error> + <Code>MissingParameter</Code> + <Message>The request must contain the parameter DomainName</Message> + <BoxUsage>0.0055590278</BoxUsage> + </Error> + </Errors> + <RequestID>967d9adf-facb-20c1-a63a-9d3f9f8fb1a4</RequestID> +</Response> === added file 'txaws/db/tests/data/get_attributes.xml' --- txaws/db/tests/data/get_attributes.xml 1970-01-01 00:00:00 +0000 +++ txaws/db/tests/data/get_attributes.xml 2012-01-13 16:49:25 +0000 @@ -0,0 +1,13 @@ +<?xml version="1.0"?> +<GetAttributesResponse xmlns="http://sdb.amazonaws.com/doc/2009-04-15/"> + <GetAttributesResult> + <Attribute> + <Name>Attr1</Name> + <Value>1</Value> + </Attribute> + </GetAttributesResult> + <ResponseMetadata> + <RequestId>85d3b0cc-5019-82f2-4c5c-c142a235c92e</RequestId> + <BoxUsage>0.0000093222</BoxUsage> + </ResponseMetadata> +</GetAttributesResponse> === added file 'txaws/db/tests/data/list_domains.xml' --- txaws/db/tests/data/list_domains.xml 1970-01-01 00:00:00 +0000 +++ txaws/db/tests/data/list_domains.xml 2012-01-13 16:49:25 +0000 @@ -0,0 +1,11 @@ +<?xml version="1.0"?> +<ListDomainsResponse xmlns="http://sdb.amazonaws.com/doc/2009-04-15/"> + <ListDomainsResult> + <DomainName>Test</DomainName> + <NextToken>VGVzdDI=</NextToken> + </ListDomainsResult> + <ResponseMetadata> + <RequestId>761832f3-c96c-d646-d9f1-67071045715e</RequestId> + <BoxUsage>0.0000071759</BoxUsage> + </ResponseMetadata> +</ListDomainsResponse> === added file 'txaws/db/tests/data/put_attributes.xml' --- txaws/db/tests/data/put_attributes.xml 1970-01-01 00:00:00 +0000 +++ txaws/db/tests/data/put_attributes.xml 2012-01-13 16:49:25 +0000 @@ -0,0 +1,7 @@ +<?xml version="1.0"?> +<PutAttributesResponse xmlns="http://sdb.amazonaws.com/doc/2009-04-15/"> + <ResponseMetadata> + <RequestId>059a5500-6997-8cbd-dad4-37d101caf525</RequestId> + <BoxUsage>0.0000219909</BoxUsage> + </ResponseMetadata> +</PutAttributesResponse> === added file 'txaws/db/tests/data/select.xml' --- txaws/db/tests/data/select.xml 1970-01-01 00:00:00 +0000 +++ txaws/db/tests/data/select.xml 2012-01-13 16:49:25 +0000 @@ -0,0 +1,28 @@ +<?xml version="1.0"?> +<SelectResponse xmlns="http://sdb.amazonaws.com/doc/2009-04-15/"> + <SelectResult> + <Item> + <Name>Item_03</Name> + <Attribute><Name>Category</Name><Value>Clothes</Value></Attribute> + <Attribute><Name>Subcategory</Name><Value>Pants</Value></Attribute> + <Attribute><Name>Name</Name><Value>Sweatpants</Value></Attribute> + <Attribute><Name>Color</Name><Value>Blue</Value></Attribute> + <Attribute><Name>Color</Name><Value>Yellow</Value></Attribute> + <Attribute><Name>Color</Name><Value>Pink</Value></Attribute> + <Attribute><Name>Size</Name><Value>Large</Value></Attribute> + </Item> + <Item> + <Name>Item_06</Name> + <Attribute><Name>Category</Name><Value>Motorcycle Parts</Value></Attribute> + <Attribute><Name>Subcategory</Name><Value>Bodywork</Value></Attribute> + <Attribute><Name>Name</Name><Value>Fender Eliminator</Value></Attribute> + <Attribute><Name>Color</Name><Value>Blue</Value></Attribute> + <Attribute><Name>Make</Name><Value>Yamaha</Value></Attribute> + <Attribute><Name>Model</Name><Value>R1</Value></Attribute> + </Item> + </SelectResult> + <ResponseMetadata> + <RequestId>b1e8f1f7-42e9-494c-ad09-2674e557526d</RequestId> + <BoxUsage>0.0000219907</BoxUsage> + </ResponseMetadata> +</SelectResponse> === added file 'txaws/db/tests/test_client.py' --- txaws/db/tests/test_client.py 1970-01-01 00:00:00 +0000 +++ txaws/db/tests/test_client.py 2012-01-13 16:49:25 +0000 @@ -0,0 +1,488 @@ +from decimal import Decimal +from functools import partial + +from twisted.internet.defer import succeed +from twisted.python.filepath import FilePath +from twisted.trial.unittest import TestCase + +from txaws.credentials import AWSCredentials +from txaws.db import response as db_response +from txaws.db.client import ( + Query, SimpleDBClient, replace, exists, does_not_exist, + _attributes_to_parameters, quote_name, quote_value, interpolate_select) +from txaws.db.exception import SimpleDBError +from txaws.service import AWSServiceEndpoint + + + +class AttributesToParametersTests(TestCase): + """ + Tests for L{txaws.db.client.SimpleDBClient._attributes_to_parameters}. + """ + def setUp(self): + self.client = SimpleDBClient(AWSCredentials( + access_key='accessKey', secret_key='secretKey')) + + + def test_single(self): + """ + Attributes with single values. + """ + params = _attributes_to_parameters({ + 'foo': ['x'], + 'bar': ['y']}) + self.assertEquals( + {'Attribute.1.Name': 'foo', + 'Attribute.1.Value': 'x', + 'Attribute.2.Name': 'bar', + 'Attribute.2.Value': 'y'}, + params) + + + def test_multiple(self): + """ + Attributes with multiple values. + """ + params = _attributes_to_parameters({ + 'foo': ['x', 'z'], + 'bar': ['y']}) + self.assertEquals( + {'Attribute.1.Name': 'foo', + 'Attribute.1.Value': 'x', + 'Attribute.2.Name': 'foo', + 'Attribute.2.Value': 'z', + 'Attribute.3.Name': 'bar', + 'Attribute.3.Value': 'y'}, + params) + + + def test_replace(self): + """ + Replace values for an attribute. + """ + params = _attributes_to_parameters({ + 'foo': replace('x'), + 'bar': ['y']}) + self.assertEquals( + {'Attribute.1.Name': 'foo', + 'Attribute.1.Value': 'x', + 'Attribute.1.Replace': 'true', + 'Attribute.2.Name': 'bar', + 'Attribute.2.Value': 'y'}, + params) + + + def test_conditions(self): + """ + Attribute conditions. + """ + params = _attributes_to_parameters({ + 'foo': ['x'], + 'bar': ['y']}, + {'bar': 'a'}) + self.assertEquals( + {'Attribute.1.Name': 'foo', + 'Attribute.1.Value': 'x', + 'Attribute.2.Name': 'bar', + 'Attribute.2.Value': 'y', + 'Expected.1.Name': 'bar', + 'Expected.1.Value': 'a'}, + params) + + + def test_exists(self): + """ + Attribute existent conditions. + """ + params = _attributes_to_parameters({ + 'foo': ['x'], + 'bar': ['y']}, + {'foo': exists('z')}) + self.assertEquals( + {'Attribute.1.Name': 'foo', + 'Attribute.1.Value': 'x', + 'Attribute.2.Name': 'bar', + 'Attribute.2.Value': 'y', + 'Expected.1.Name': 'foo', + 'Expected.1.Value': 'z', + 'Expected.1.Exists': 'true'}, + params) + + + def test_does_not_exist(self): + """ + Attribute nonexistent conditions. + """ + params = _attributes_to_parameters({ + 'foo': ['x'], + 'bar': ['y']}, + {'foo': does_not_exist()}) + self.assertEquals( + {'Attribute.1.Name': 'foo', + 'Attribute.1.Value': 'x', + 'Attribute.2.Name': 'bar', + 'Attribute.2.Value': 'y', + 'Expected.1.Name': 'foo', + 'Expected.1.Exists': 'false'}, + params) + + + +class MockQuery(Query): + def __init__(self, check, path, *a, **kw): + self._check = check + self._path = path + super(MockQuery, self).__init__(*a, **kw) + + + def get_query_payload(self): + if self._path is None: + data = None + elif isinstance(self._path, FilePath): + data = self._path.getContent() + else: + data = self._path + return data + + + def get_page(self, url, *a, **kw): + return succeed(self.get_query_payload()) + + + def submit(self): + self._check(self) + d = self.get_page(None) + d.addErrback(self._handle_error) + return d + + + +class BrokenQuery(MockQuery): + def __init__(self, code, message, *a, **kw): + self._code = code + self._message = message + super(BrokenQuery, self).__init__(*a, **kw) + + + def get_page(self, url, *a, **kw): + from twisted.web.error import Error + from twisted.internet.defer import fail + return fail(Error(self._code, self._message, self.get_query_payload())) + + + +class QuotingTests(TestCase): + """ + Tests for L{txaws.db.client.quote_name} and L{txaws.db.client.quote_value}. + """ + def test_quote_name(self): + """ + Names are quoted with backticks (I{`}) and backticks contained in the + name are escaped with a backtick. + """ + cases = [ + (u'foo', u'`foo`'), + (u'foo`bar', u'`foo``bar`'), + (u"it's", u"`it's`"), + (u"i`t's", u"`i``t's`"), + (u"i`t's\"", u"`i``t's\"`")] + for value, expected in cases: + self.assertEquals(expected, quote_name(value)) + + + def test_quote_value(self): + """ + Values are quoted with single-quotes (I{'}) and single-quotes contained + in the value are escaped with a single-quote. + """ + cases = [ + (u'foo', u"'foo'"), + (u'foo`bar', u"'foo`bar'"), + (u"it's", u"'it''s'"), + (u"i`t's", u"'i`t''s'"), + (u"i`t's\"", u"'i`t''s\"'")] + for value, expected in cases: + self.assertEquals(expected, quote_value(value)) + + + +class InterpolationTests(TestCase): + """ + Tests for L{txaws.db.client.interpolate_select}. + """ + def test_consenting_adults(self): + """ + Using the value interpolation character performs value quoting, + likewise using the name interpolation character performs name quoting. + Getting it wrong means getting a broken query. + """ + self.assertEquals( + u"SELECT 'x', `y`, `z` FROM 'domain'", + interpolate_select( + u'SELECT ?, #, # FROM ?', + ('x', 'y', 'z', 'domain'))) + + + def test_full(self): + """ + Names are quoted in interpolated names (denoted by a I{#}), values are + quoted in interpolated values (denoted by a I{?}). + """ + self.assertEquals( + u"SELECT `x`, `y`, `z` FROM `dom``ain` " + u"WHERE `attr1` = 'a''z' AND `attr2` = 'b'", + interpolate_select( + u'SELECT #, #, # FROM # WHERE # = ? AND # = ?', + ('x', 'y', 'z', 'dom`ain', 'attr1', "a'z", 'attr2', 'b'))) + + + def test_too_few_values(self): + """ + Passing fewer values than interpolation characters results in + C{ValueError} being raised. + """ + e = self.assertRaises(ValueError, + interpolate_select, u'SELECT #, ?', ('x',)) + self.assertEquals('Too few values', str(e)) + + + +class SimpleDBClientTests(TestCase): + """ + Tests for L{txaws.db.client.SimpleDBClient}. + """ + def setUp(self): + super(SimpleDBClientTests, self).setUp() + self.creds = AWSCredentials( + access_key='accessKey', secret_key='secretKey') + self.endpoint = AWSServiceEndpoint() + self.dataPath = FilePath(__file__).sibling('data') + + + def test_unknown_response(self): + data = '<UnknownResponse />' + client = SimpleDBClient( + self.creds, query_factory=partial(MockQuery, lambda q: None, data)) + d = client.create_domain('Test') + return self.assertFailure(d, ValueError) + + + def test_box_usage(self): + """ + Response objects have a C{box_usage} attribute that is added to + L{SimpleDBClient.total_box_usage}. + """ + def checkResponse(response): + self.assertEquals(Decimal('0.0055590278'), response.box_usage) + return client.total_box_usage + + path = self.dataPath.child('create_domain.xml') + client = SimpleDBClient( + self.creds, query_factory=partial(MockQuery, lambda q: None, path)) + self.assertEquals(0, client.total_box_usage) + d = client.create_domain('Test') + d.addCallback(checkResponse) + d.addCallback(self.assertEquals, Decimal('0.0055590278')) + d.addCallback(lambda ign: client.create_domain('Test')) + d.addCallback(checkResponse) + d.addCallback(self.assertEquals, Decimal('0.0111180556')) + return d + + + def test_error(self): + def checkFailure(e): + self.assertEquals(Decimal('0.0055590278'), e.response.box_usage) + self.assertEquals( + 'Error Message: The request must contain the parameter DomainName', + str(e)) + return client.total_box_usage + + path = self.dataPath.child('error.xml') + client = SimpleDBClient( + self.creds, query_factory=partial( + BrokenQuery, + code=400, + message='Bad request', + check=lambda q: None, + path=path)) + self.assertEquals(0, client.total_box_usage) + d = client.create_domain('') + d = self.assertFailure(d, SimpleDBError) + d.addCallback(checkFailure) + d.addCallback(self.assertEquals, Decimal('0.0055590278')) + return d + + + def test_create_domain(self): + def checkQuery(query): + self.assertEquals('CreateDomain', query.action) + self.assertEquals('Test', query.params['DomainName']) + + def checkResponse(response): + self.assertIdentical( + db_response.CreateDomainResponse, type(response)) + + path = self.dataPath.child('create_domain.xml') + client = SimpleDBClient( + self.creds, query_factory=partial(MockQuery, checkQuery, path)) + d = client.create_domain('Test') + d.addCallback(checkResponse) + return d + + + def test_delete_domain(self): + def checkQuery(query): + self.assertEquals('DeleteDomain', query.action) + self.assertEquals('Test', query.params['DomainName']) + + def checkResponse(response): + self.assertIdentical( + db_response.DeleteDomainResponse, type(response)) + + path = self.dataPath.child('delete_domain.xml') + client = SimpleDBClient( + self.creds, query_factory=partial(MockQuery, checkQuery, path)) + d = client.delete_domain('Test') + d.addCallback(checkResponse) + return d + + + def test_list_domains(self): + def checkQuery(query): + self.assertEquals('ListDomains', query.action) + self.assertEquals('1', query.params['MaxNumberOfDomains']) + + def checkResponse(response): + self.assertIdentical( + db_response.ListDomainsResponse, type(response)) + self.assertEquals('VGVzdDI=', response.next_token) + + path = self.dataPath.child('list_domains.xml') + client = SimpleDBClient( + self.creds, query_factory=partial(MockQuery, checkQuery, path)) + d = client.list_domains(max_num_domains=1) + d.addCallback(checkResponse) + return d + + + def test_domain_metadata(self): + def checkQuery(query): + self.assertEquals('DomainMetadata', query.action) + self.assertEquals('Test', query.params['DomainName']) + + def checkResponse(response): + self.assertIdentical( + db_response.DomainMetadataResponse, type(response)) + self.assertEquals( + {'Timestamp': '1325687708', + 'AttributeValueCount': '0', + 'AttributeValuesSizeBytes': '0', + 'ItemNamesSizeBytes': '0', + 'AttributeNameCount': '0', + 'ItemCount': '0', + 'AttributeNamesSizeBytes': '0'}, + response.metadata) + + path = self.dataPath.child('domain_metadata.xml') + client = SimpleDBClient( + self.creds, query_factory=partial(MockQuery, checkQuery, path)) + d = client.domain_metadata('Test') + d.addCallback(checkResponse) + return d + + + def test_put_attributes(self): + def checkQuery(query): + self.assertEquals('PutAttributes', query.action) + self.assertEquals('Test', query.params['DomainName']) + self.assertEquals('Item1', query.params['ItemName']) + self.assertEquals('Attr1', query.params['Attribute.1.Name']) + self.assertEquals('1', query.params['Attribute.1.Value']) + + def checkResponse(response): + self.assertIdentical( + db_response.PutAttributesResponse, type(response)) + + path = self.dataPath.child('put_attributes.xml') + client = SimpleDBClient( + self.creds, query_factory=partial(MockQuery, checkQuery, path)) + d = client.put_attributes('Test', 'Item1', {'Attr1': ['1']}) + d.addCallback(checkResponse) + return d + + + def test_delete_attributes(self): + def checkQuery(query): + self.assertEquals('DeleteAttributes', query.action) + self.assertEquals('Test', query.params['DomainName']) + self.assertEquals('Item1', query.params['ItemName']) + + def checkResponse(response): + self.assertIdentical( + db_response.DeleteAttributesResponse, type(response)) + + path = self.dataPath.child('delete_attributes.xml') + client = SimpleDBClient( + self.creds, query_factory=partial(MockQuery, checkQuery, path)) + d = client.delete_attributes('Test', 'Item1', None) + d.addCallback(checkResponse) + return d + + + def test_get_attributes(self): + def checkQuery(query): + self.assertEquals('GetAttributes', query.action) + self.assertEquals('Test', query.params['DomainName']) + self.assertEquals('Item1', query.params['ItemName']) + self.assertEquals('Attr1', query.params['AttributeName.1']) + self.assertEquals('false', query.params['ConsistentRead']) + + def checkResponse(response): + self.assertIdentical( + db_response.GetAttributesResponse, type(response)) + self.assertEquals( + {'Attr1': ['1']}, + response.attributes) + + path = self.dataPath.child('get_attributes.xml') + client = SimpleDBClient( + self.creds, query_factory=partial(MockQuery, checkQuery, path)) + d = client.get_attributes('Test', 'Item1', ['Attr1']) + d.addCallback(checkResponse) + return d + + + def test_select(self): + def checkQuery(query): + self.assertEquals('Select', query.action) + self.assertEquals( + "select `Color` from `MyDomain` where `Color` like 'Blue%'", + query.params['SelectExpression']) + + def checkResponse(response): + self.assertIdentical( + db_response.SelectResponse, type(response)) + expected = [ + ('Item_03', { + 'Category': ['Clothes'], + 'Color': ['Blue', 'Yellow', 'Pink'], + 'Name': ['Sweatpants'], + 'Size': ['Large'], + 'Subcategory': ['Pants']}), + ('Item_06', { + 'Category': ['Motorcycle Parts'], + 'Color': ['Blue'], + 'Make': ['Yamaha'], + 'Model': ['R1'], + 'Name': ['Fender Eliminator'], + 'Subcategory': ['Bodywork']})] + self.assertEquals(expected, response.items) + + path = self.dataPath.child('select.xml') + client = SimpleDBClient( + self.creds, query_factory=partial(MockQuery, checkQuery, path)) + d = client.select( + 'select # from # where # like ?', + ('Color', 'MyDomain', 'Color', 'Blue%',)) + d.addCallback(checkResponse) + return d === added file 'txaws/db/tests/test_response.py' --- txaws/db/tests/test_response.py 1970-01-01 00:00:00 +0000 +++ txaws/db/tests/test_response.py 2012-01-13 16:49:25 +0000 @@ -0,0 +1,170 @@ +from twisted.python.filepath import FilePath +from twisted.trial.unittest import TestCase + +from txaws.db.response import ( + _repr_attrs, BaseResponse, CreateDomainResponse, DeleteDomainResponse, + ListDomainsResponse, DomainMetadataResponse, PutAttributesResponse, + DeleteAttributesResponse, GetAttributesResponse, SelectResponse, + ErrorResponse) +from txaws.util import XML + + + +class BaseResponseTests(TestCase): + """ + Tests for L{txaws.db.response.BaseResponse}. + """ + response_type = None + response_filename = None + + + def make_response(self, filename): + path = FilePath(__file__).sibling('data').child(filename) + tree = XML(path.getContent()) + return self.response_type(tree) + + + def expected_repr(self, response, **kw): + kw.setdefault('box_usage', response.box_usage) + kw.setdefault('request_id', response.request_id) + return '<%s %s>' % ( + type(response).__name__, + ' '.join(_repr_attrs(kw.items()))) + + + def test_repr(self): + """ + The C{repr} output of the response is a human readable format that + accurately describes the response and its attributes. + """ + if self.response_type is None: + return + response = self.make_response(self.response_filename) + self.assertEquals( + self.expected_repr(response), + repr(response)) + + + +class CreateDomainResponseTests(BaseResponseTests): + """ + Tests for L{txaws.db.response.CreateDomainResponse}. + """ + response_type = CreateDomainResponse + response_filename = 'create_domain.xml' + + + +class DeleteDomainResponseTests(BaseResponseTests): + """ + Tests for L{txaws.db.response.DeleteDomainResponse}. + """ + response_type = DeleteDomainResponse + response_filename = 'delete_domain.xml' + + + +class ListDomainsResponseTests(BaseResponseTests): + """ + Tests for L{txaws.db.response.ListDomainsResponse}. + """ + response_type = ListDomainsResponse + response_filename = 'list_domains.xml' + + + def test_repr(self): + response = self.make_response(self.response_filename) + self.assertEquals( + self.expected_repr( + response, + domains=response.domains, + next_token=response.next_token), + repr(response)) + + + +class DomainMetadataResponseTests(BaseResponseTests): + """ + Tests for L{txaws.db.response.DomainMetadataResponse}. + """ + response_type = DomainMetadataResponse + response_filename = 'domain_metadata.xml' + + + def test_repr(self): + response = self.make_response(self.response_filename) + self.assertEquals( + self.expected_repr(response, metadata=response.metadata), + repr(response)) + + + +class PutAttributesResponseTests(BaseResponseTests): + """ + Tests for L{txaws.db.response.PutAttributesResponse}. + """ + response_type = PutAttributesResponse + response_filename = 'put_attributes.xml' + + + +class DeleteAttributesResponseTests(BaseResponseTests): + """ + Tests for L{txaws.db.response.DeleteAttributesResponse}. + """ + response_type = DeleteAttributesResponse + response_filename = 'delete_attributes.xml' + + + +class GetAttributesResponseTests(BaseResponseTests): + """ + Tests for L{txaws.db.response.GetAttributesResponse}. + """ + response_type = GetAttributesResponse + response_filename = 'get_attributes.xml' + + + def test_repr(self): + response = self.make_response(self.response_filename) + self.assertEquals( + self.expected_repr(response, attributes=response.attributes), + repr(response)) + + + +class SelectResponseTests(BaseResponseTests): + """ + Tests for L{txaws.db.response.SelectResponse}. + """ + response_type = SelectResponse + response_filename = 'select.xml' + + + def test_repr(self): + response = self.make_response(self.response_filename) + self.assertEquals( + self.expected_repr( + response, + items=response.items, + next_token=response.next_token), + repr(response)) + + + +class ErrorResponseTests(BaseResponseTests): + """ + Tests for L{txaws.db.response.ErrorResponse}. + """ + response_type = ErrorResponse + response_filename = 'error.xml' + + + def test_repr(self): + response = self.make_response(self.response_filename) + self.assertEquals( + self.expected_repr( + response, + box_usage=None, + errors=response.errors), + repr(response)) === modified file 'txaws/ec2/client.py' --- txaws/ec2/client.py 2011-08-19 16:09:39 +0000 +++ txaws/ec2/client.py 2012-01-13 16:49:25 +0000 @@ -954,8 +954,14 @@ kwargs["headers"] = headers if self.timeout: kwargs["timeout"] = self.timeout + print repr(url) d = self.get_page(url, **kwargs) - return d.addErrback(ec2_error_wrapper) + return d.addErrback(self._handle_error) + + + def _handle_error(self, f): + return ec2_error_wrapper(f) + class Signature(object): === modified file 'txaws/service.py' --- txaws/service.py 2011-11-29 08:17:54 +0000 +++ txaws/service.py 2012-01-13 16:49:25 +0000 @@ -13,6 +13,7 @@ EC2_ENDPOINT_US = "https://us-east-1.ec2.amazonaws.com/" EC2_ENDPOINT_EU = "https://eu-west-1.ec2.amazonaws.com/" S3_ENDPOINT = "https://s3.amazonaws.com/" +SDB_ENDPOINT = "https://sdb.amazonaws.com" class AWSServiceEndpoint(object): @@ -100,7 +101,7 @@ # XXX update unit test to check for both ec2 and s3 endpoints def __init__(self, creds=None, access_key="", secret_key="", region=REGION_US, uri="", ec2_uri="", s3_uri="", - method="GET"): + sdb_uri="", method="GET"): if not creds: creds = AWSCredentials(access_key, secret_key) self.creds = creds @@ -113,9 +114,12 @@ ec2_uri = EC2_ENDPOINT_EU if not s3_uri: s3_uri = S3_ENDPOINT + if not sdb_uri: + sdb_uri = SDB_ENDPOINT self._clients = {} self.ec2_endpoint = AWSServiceEndpoint(uri=ec2_uri, method=method) self.s3_endpoint = AWSServiceEndpoint(uri=s3_uri, method=method) + self.sdb_endpoint = AWSServiceEndpoint(uri=sdb_uri, method=method) def get_client(self, cls, purge_cache=False, *args, **kwds): """ @@ -146,3 +150,11 @@ self.creds = creds return self.get_client(S3Client, creds=self.creds, endpoint=self.s3_endpoint, query_factory=None) + + + def get_sdb_client(self, creds=None): + from txaws.db.client import SimpleDBClient + if creds: + self.creds = creds + return self.get_client(SimpleDBClient, creds=self.creds, + endpoint=self.sdb_endpoint, query_factory=None) === modified file 'txaws/version.py' --- txaws/version.py 2011-11-29 08:17:54 +0000 +++ txaws/version.py 2012-01-13 16:49:25 +0000 @@ -1,3 +1,4 @@ txaws = "0.2.2" ec2_api = "2008-12-01" s3_api = "2006-03-01" +sdb_api = "2009-04-15"
_______________________________________________ Mailing list: https://launchpad.net/~txaws-dev Post to : [email protected] Unsubscribe : https://launchpad.net/~txaws-dev More help : https://help.launchpad.net/ListHelp

