← Back to team overview

txaws-dev team mailing list archive

[Merge] lp:~txaws-dev/txaws/415691-add-simpledb into lp:txaws

 

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 <mithrandi@xxxxxxxxxxxxx>
+Copyright (C) 2009 Robert Collins <robertc@xxxxxxxxxxxxxxxxx>
+Copyright (C) 2009 Canonical Ltd
+Copyright (C) 2009 Duncan McGreggor <oubiwann@xxxxxxxxx>
+
+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 <mithrandi@xxxxxxxxxxxxx>
-Copyright (C) 2009 Robert Collins <robertc@xxxxxxxxxxxxxxxxx>
-Copyright (C) 2009 Canonical Ltd
-Copyright (C) 2009 Duncan McGreggor <oubiwann@xxxxxxxxx>
-
-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"


Follow ups