1# Copyright (c) 2006,2007 Mitch Garnaat http://garnaat.org/
2#
3# Permission is hereby granted, free of charge, to any person obtaining a
4# copy of this software and associated documentation files (the
5# "Software"), to deal in the Software without restriction, including
6# without limitation the rights to use, copy, modify, merge, publish, dis-
7# tribute, sublicense, and/or sell copies of the Software, and to permit
8# persons to whom the Software is furnished to do so, subject to the fol-
9# lowing conditions:
10#
11# The above copyright notice and this permission notice shall be included
12# in all copies or substantial portions of the Software.
13#
14# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABIL-
16# ITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
17# SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
18# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
20# IN THE SOFTWARE.
21import xml.sax
22import threading
23import boto
24from boto import handler
25from boto.connection import AWSQueryConnection
26from boto.sdb.domain import Domain, DomainMetaData
27from boto.sdb.item import Item
28from boto.sdb.regioninfo import SDBRegionInfo
29from boto.exception import SDBResponseError
30
31class ItemThread(threading.Thread):
32    """
33    A threaded :class:`Item <boto.sdb.item.Item>` retriever utility class.
34    Retrieved :class:`Item <boto.sdb.item.Item>` objects are stored in the
35    ``items`` instance variable after :py:meth:`run() <run>` is called.
36
37    .. tip:: The item retrieval will not start until
38        the :func:`run() <boto.sdb.connection.ItemThread.run>` method is called.
39    """
40    def __init__(self, name, domain_name, item_names):
41        """
42        :param str name: A thread name. Used for identification.
43        :param str domain_name: The name of a SimpleDB
44            :class:`Domain <boto.sdb.domain.Domain>`
45        :type item_names: string or list of strings
46        :param item_names: The name(s) of the items to retrieve from the specified
47            :class:`Domain <boto.sdb.domain.Domain>`.
48        :ivar list items: A list of items retrieved. Starts as empty list.
49        """
50        super(ItemThread, self).__init__(name=name)
51        #print 'starting %s with %d items' % (name, len(item_names))
52        self.domain_name = domain_name
53        self.conn = SDBConnection()
54        self.item_names = item_names
55        self.items = []
56
57    def run(self):
58        """
59        Start the threaded retrieval of items. Populates the
60        ``items`` list with :class:`Item <boto.sdb.item.Item>` objects.
61        """
62        for item_name in self.item_names:
63            item = self.conn.get_attributes(self.domain_name, item_name)
64            self.items.append(item)
65
66#boto.set_stream_logger('sdb')
67
68class SDBConnection(AWSQueryConnection):
69    """
70    This class serves as a gateway to your SimpleDB region (defaults to
71    us-east-1). Methods within allow access to SimpleDB
72    :class:`Domain <boto.sdb.domain.Domain>` objects and their associated
73    :class:`Item <boto.sdb.item.Item>` objects.
74
75    .. tip::
76        While you may instantiate this class directly, it may be easier to
77        go through :py:func:`boto.connect_sdb`.
78    """
79    DefaultRegionName = 'us-east-1'
80    DefaultRegionEndpoint = 'sdb.us-east-1.amazonaws.com'
81    APIVersion = '2009-04-15'
82    ResponseError = SDBResponseError
83
84    def __init__(self, aws_access_key_id=None, aws_secret_access_key=None,
85                 is_secure=True, port=None, proxy=None, proxy_port=None,
86                 proxy_user=None, proxy_pass=None, debug=0,
87                 https_connection_factory=None, region=None, path='/',
88                 converter=None, security_token=None, validate_certs=True,
89                 profile_name=None):
90        """
91        For any keywords that aren't documented, refer to the parent class,
92        :py:class:`boto.connection.AWSAuthConnection`. You can avoid having
93        to worry about these keyword arguments by instantiating these objects
94        via :py:func:`boto.connect_sdb`.
95
96        :type region: :class:`boto.sdb.regioninfo.SDBRegionInfo`
97        :keyword region: Explicitly specify a region. Defaults to ``us-east-1``
98            if not specified. You may also specify the region in your ``boto.cfg``:
99
100            .. code-block:: cfg
101
102                [SDB]
103                region = eu-west-1
104
105        """
106        if not region:
107            region_name = boto.config.get('SDB', 'region', self.DefaultRegionName)
108            for reg in boto.sdb.regions():
109                if reg.name == region_name:
110                    region = reg
111                    break
112
113        self.region = region
114        super(SDBConnection, self).__init__(aws_access_key_id,
115                                    aws_secret_access_key,
116                                    is_secure, port, proxy,
117                                    proxy_port, proxy_user, proxy_pass,
118                                    self.region.endpoint, debug,
119                                    https_connection_factory, path,
120                                    security_token=security_token,
121                                    validate_certs=validate_certs,
122                                    profile_name=profile_name)
123        self.box_usage = 0.0
124        self.converter = converter
125        self.item_cls = Item
126
127    def _required_auth_capability(self):
128        return ['sdb']
129
130    def set_item_cls(self, cls):
131        """
132        While the default item class is :py:class:`boto.sdb.item.Item`, this
133        default may be overridden. Use this method to change a connection's
134        item class.
135
136        :param object cls: The new class to set as this connection's item
137            class. See the default item class for inspiration as to what your
138            replacement should/could look like.
139        """
140        self.item_cls = cls
141
142    def _build_name_value_list(self, params, attributes, replace=False,
143                              label='Attribute'):
144        keys = sorted(attributes.keys())
145        i = 1
146        for key in keys:
147            value = attributes[key]
148            if isinstance(value, list):
149                for v in value:
150                    params['%s.%d.Name' % (label, i)] = key
151                    if self.converter:
152                        v = self.converter.encode(v)
153                    params['%s.%d.Value' % (label, i)] = v
154                    if replace:
155                        params['%s.%d.Replace' % (label, i)] = 'true'
156                    i += 1
157            else:
158                params['%s.%d.Name' % (label, i)] = key
159                if self.converter:
160                    value = self.converter.encode(value)
161                params['%s.%d.Value' % (label, i)] = value
162                if replace:
163                    params['%s.%d.Replace' % (label, i)] = 'true'
164            i += 1
165
166    def _build_expected_value(self, params, expected_value):
167        params['Expected.1.Name'] = expected_value[0]
168        if expected_value[1] is True:
169            params['Expected.1.Exists'] = 'true'
170        elif expected_value[1] is False:
171            params['Expected.1.Exists'] = 'false'
172        else:
173            params['Expected.1.Value'] = expected_value[1]
174
175    def _build_batch_list(self, params, items, replace=False):
176        item_names = items.keys()
177        i = 0
178        for item_name in item_names:
179            params['Item.%d.ItemName' % i] = item_name
180            j = 0
181            item = items[item_name]
182            if item is not None:
183                attr_names = item.keys()
184                for attr_name in attr_names:
185                    value = item[attr_name]
186                    if isinstance(value, list):
187                        for v in value:
188                            if self.converter:
189                                v = self.converter.encode(v)
190                            params['Item.%d.Attribute.%d.Name' % (i, j)] = attr_name
191                            params['Item.%d.Attribute.%d.Value' % (i, j)] = v
192                            if replace:
193                                params['Item.%d.Attribute.%d.Replace' % (i, j)] = 'true'
194                            j += 1
195                    else:
196                        params['Item.%d.Attribute.%d.Name' % (i, j)] = attr_name
197                        if self.converter:
198                            value = self.converter.encode(value)
199                        params['Item.%d.Attribute.%d.Value' % (i, j)] = value
200                        if replace:
201                            params['Item.%d.Attribute.%d.Replace' % (i, j)] = 'true'
202                        j += 1
203            i += 1
204
205    def _build_name_list(self, params, attribute_names):
206        i = 1
207        attribute_names.sort()
208        for name in attribute_names:
209            params['Attribute.%d.Name' % i] = name
210            i += 1
211
212    def get_usage(self):
213        """
214        Returns the BoxUsage (in USD) accumulated on this specific SDBConnection
215        instance.
216
217        .. tip:: This can be out of date, and should only be treated as a
218            rough estimate. Also note that this estimate only applies to the
219            requests made on this specific connection instance. It is by
220            no means an account-wide estimate.
221
222        :rtype: float
223        :return: The accumulated BoxUsage of all requests made on the connection.
224        """
225        return self.box_usage
226
227    def print_usage(self):
228        """
229        Print the BoxUsage and approximate costs of all requests made on
230        this specific SDBConnection instance.
231
232        .. tip:: This can be out of date, and should only be treated as a
233            rough estimate. Also note that this estimate only applies to the
234            requests made on this specific connection instance. It is by
235            no means an account-wide estimate.
236        """
237        print('Total Usage: %f compute seconds' % self.box_usage)
238        cost = self.box_usage * 0.14
239        print('Approximate Cost: $%f' % cost)
240
241    def get_domain(self, domain_name, validate=True):
242        """
243        Retrieves a :py:class:`boto.sdb.domain.Domain` object whose name
244        matches ``domain_name``.
245
246        :param str domain_name: The name of the domain to retrieve
247        :keyword bool validate: When ``True``, check to see if the domain
248            actually exists. If ``False``, blindly return a
249            :py:class:`Domain <boto.sdb.domain.Domain>` object with the
250            specified name set.
251
252        :raises:
253            :py:class:`boto.exception.SDBResponseError` if ``validate`` is
254            ``True`` and no match could be found.
255
256        :rtype: :py:class:`boto.sdb.domain.Domain`
257        :return: The requested domain
258        """
259        domain = Domain(self, domain_name)
260        if validate:
261            self.select(domain, """select * from `%s` limit 1""" % domain_name)
262        return domain
263
264    def lookup(self, domain_name, validate=True):
265        """
266        Lookup an existing SimpleDB domain. This differs from
267        :py:meth:`get_domain` in that ``None`` is returned if ``validate`` is
268        ``True`` and no match was found (instead of raising an exception).
269
270        :param str domain_name: The name of the domain to retrieve
271
272        :param bool validate: If ``True``, a ``None`` value will be returned
273            if the specified domain can't be found. If ``False``, a
274            :py:class:`Domain <boto.sdb.domain.Domain>` object will be dumbly
275            returned, regardless of whether it actually exists.
276
277        :rtype: :class:`boto.sdb.domain.Domain` object or ``None``
278        :return: The Domain object or ``None`` if the domain does not exist.
279        """
280        try:
281            domain = self.get_domain(domain_name, validate)
282        except:
283            domain = None
284        return domain
285
286    def get_all_domains(self, max_domains=None, next_token=None):
287        """
288        Returns a :py:class:`boto.resultset.ResultSet` containing
289        all :py:class:`boto.sdb.domain.Domain` objects associated with
290        this connection's Access Key ID.
291
292        :keyword int max_domains: Limit the returned
293            :py:class:`ResultSet <boto.resultset.ResultSet>` to the specified
294            number of members.
295        :keyword str next_token: A token string that was returned in an
296            earlier call to this method as the ``next_token`` attribute
297            on the returned :py:class:`ResultSet <boto.resultset.ResultSet>`
298            object. This attribute is set if there are more than Domains than
299            the value specified in the ``max_domains`` keyword. Pass the
300            ``next_token`` value from you earlier query in this keyword to
301            get the next 'page' of domains.
302        """
303        params = {}
304        if max_domains:
305            params['MaxNumberOfDomains'] = max_domains
306        if next_token:
307            params['NextToken'] = next_token
308        return self.get_list('ListDomains', params, [('DomainName', Domain)])
309
310    def create_domain(self, domain_name):
311        """
312        Create a SimpleDB domain.
313
314        :type domain_name: string
315        :param domain_name: The name of the new domain
316
317        :rtype: :class:`boto.sdb.domain.Domain` object
318        :return: The newly created domain
319        """
320        params = {'DomainName': domain_name}
321        d = self.get_object('CreateDomain', params, Domain)
322        d.name = domain_name
323        return d
324
325    def get_domain_and_name(self, domain_or_name):
326        """
327        Given a ``str`` or :class:`boto.sdb.domain.Domain`, return a
328        ``tuple`` with the following members (in order):
329
330            * In instance of :class:`boto.sdb.domain.Domain` for the requested
331              domain
332            * The domain's name as a ``str``
333
334        :type domain_or_name: ``str`` or :class:`boto.sdb.domain.Domain`
335        :param domain_or_name: The domain or domain name to get the domain
336            and name for.
337
338        :raises: :class:`boto.exception.SDBResponseError` when an invalid
339            domain name is specified.
340
341        :rtype: tuple
342        :return: A ``tuple`` with contents outlined as per above.
343        """
344        if (isinstance(domain_or_name, Domain)):
345            return (domain_or_name, domain_or_name.name)
346        else:
347            return (self.get_domain(domain_or_name), domain_or_name)
348
349    def delete_domain(self, domain_or_name):
350        """
351        Delete a SimpleDB domain.
352
353        .. caution:: This will delete the domain and all items within the domain.
354
355        :type domain_or_name: string or :class:`boto.sdb.domain.Domain` object.
356        :param domain_or_name: Either the name of a domain or a Domain object
357
358        :rtype: bool
359        :return: True if successful
360
361        """
362        domain, domain_name = self.get_domain_and_name(domain_or_name)
363        params = {'DomainName': domain_name}
364        return self.get_status('DeleteDomain', params)
365
366    def domain_metadata(self, domain_or_name):
367        """
368        Get the Metadata for a SimpleDB domain.
369
370        :type domain_or_name: string or :class:`boto.sdb.domain.Domain` object.
371        :param domain_or_name: Either the name of a domain or a Domain object
372
373        :rtype: :class:`boto.sdb.domain.DomainMetaData` object
374        :return: The newly created domain metadata object
375        """
376        domain, domain_name = self.get_domain_and_name(domain_or_name)
377        params = {'DomainName': domain_name}
378        d = self.get_object('DomainMetadata', params, DomainMetaData)
379        d.domain = domain
380        return d
381
382    def put_attributes(self, domain_or_name, item_name, attributes,
383                       replace=True, expected_value=None):
384        """
385        Store attributes for a given item in a domain.
386
387        :type domain_or_name: string or :class:`boto.sdb.domain.Domain` object.
388        :param domain_or_name: Either the name of a domain or a Domain object
389
390        :type item_name: string
391        :param item_name: The name of the item whose attributes are being
392                          stored.
393
394        :type attribute_names: dict or dict-like object
395        :param attribute_names: The name/value pairs to store as attributes
396
397        :type expected_value: list
398        :param expected_value: If supplied, this is a list or tuple consisting
399            of a single attribute name and expected value. The list can be
400            of the form:
401
402                * ['name', 'value']
403
404            In which case the call will first verify that the attribute "name"
405            of this item has a value of "value".  If it does, the delete
406            will proceed, otherwise a ConditionalCheckFailed error will be
407            returned. The list can also be of the form:
408
409                * ['name', True|False]
410
411            which will simply check for the existence (True) or
412            non-existence (False) of the attribute.
413
414        :type replace: bool
415        :param replace: Whether the attribute values passed in will replace
416                        existing values or will be added as addition values.
417                        Defaults to True.
418
419        :rtype: bool
420        :return: True if successful
421        """
422        domain, domain_name = self.get_domain_and_name(domain_or_name)
423        params = {'DomainName': domain_name,
424                  'ItemName': item_name}
425        self._build_name_value_list(params, attributes, replace)
426        if expected_value:
427            self._build_expected_value(params, expected_value)
428        return self.get_status('PutAttributes', params)
429
430    def batch_put_attributes(self, domain_or_name, items, replace=True):
431        """
432        Store attributes for multiple items in a domain.
433
434        :type domain_or_name: string or :class:`boto.sdb.domain.Domain` object.
435        :param domain_or_name: Either the name of a domain or a Domain object
436
437        :type items: dict or dict-like object
438        :param items: A dictionary-like object.  The keys of the dictionary are
439                      the item names and the values are themselves dictionaries
440                      of attribute names/values, exactly the same as the
441                      attribute_names parameter of the scalar put_attributes
442                      call.
443
444        :type replace: bool
445        :param replace: Whether the attribute values passed in will replace
446                        existing values or will be added as addition values.
447                        Defaults to True.
448
449        :rtype: bool
450        :return: True if successful
451        """
452        domain, domain_name = self.get_domain_and_name(domain_or_name)
453        params = {'DomainName': domain_name}
454        self._build_batch_list(params, items, replace)
455        return self.get_status('BatchPutAttributes', params, verb='POST')
456
457    def get_attributes(self, domain_or_name, item_name, attribute_names=None,
458                       consistent_read=False, item=None):
459        """
460        Retrieve attributes for a given item in a domain.
461
462        :type domain_or_name: string or :class:`boto.sdb.domain.Domain` object.
463        :param domain_or_name: Either the name of a domain or a Domain object
464
465        :type item_name: string
466        :param item_name: The name of the item whose attributes are
467            being retrieved.
468
469        :type attribute_names: string or list of strings
470        :param attribute_names: An attribute name or list of attribute names.
471            This parameter is optional.  If not supplied, all attributes will
472            be retrieved for the item.
473
474        :type consistent_read: bool
475        :param consistent_read: When set to true, ensures that the most recent
476            data is returned.
477
478        :type item: :class:`boto.sdb.item.Item`
479        :keyword item: Instead of instantiating a new Item object, you may
480            specify one to update.
481
482        :rtype: :class:`boto.sdb.item.Item`
483        :return: An Item with the requested attribute name/values set on it
484        """
485        domain, domain_name = self.get_domain_and_name(domain_or_name)
486        params = {'DomainName': domain_name,
487                  'ItemName': item_name}
488        if consistent_read:
489            params['ConsistentRead'] = 'true'
490        if attribute_names:
491            if not isinstance(attribute_names, list):
492                attribute_names = [attribute_names]
493            self.build_list_params(params, attribute_names, 'AttributeName')
494        response = self.make_request('GetAttributes', params)
495        body = response.read()
496        if response.status == 200:
497            if item is None:
498                item = self.item_cls(domain, item_name)
499            h = handler.XmlHandler(item, self)
500            xml.sax.parseString(body, h)
501            return item
502        else:
503            raise SDBResponseError(response.status, response.reason, body)
504
505    def delete_attributes(self, domain_or_name, item_name, attr_names=None,
506                          expected_value=None):
507        """
508        Delete attributes from a given item in a domain.
509
510        :type domain_or_name: string or :class:`boto.sdb.domain.Domain` object.
511        :param domain_or_name: Either the name of a domain or a Domain object
512
513        :type item_name: string
514        :param item_name: The name of the item whose attributes are being
515                          deleted.
516
517        :type attributes: dict, list or :class:`boto.sdb.item.Item`
518        :param attributes: Either a list containing attribute names which
519                           will cause all values associated with that attribute
520                           name to be deleted or a dict or Item containing the
521                           attribute names and keys and list of values to
522                           delete as the value.  If no value is supplied,
523                           all attribute name/values for the item will be
524                           deleted.
525
526        :type expected_value: list
527        :param expected_value: If supplied, this is a list or tuple consisting
528            of a single attribute name and expected value. The list can be
529            of the form:
530
531                * ['name', 'value']
532
533            In which case the call will first verify that the attribute "name"
534            of this item has a value of "value".  If it does, the delete
535            will proceed, otherwise a ConditionalCheckFailed error will be
536            returned. The list can also be of the form:
537
538                * ['name', True|False]
539
540            which will simply check for the existence (True) or
541            non-existence (False) of the attribute.
542
543        :rtype: bool
544        :return: True if successful
545        """
546        domain, domain_name = self.get_domain_and_name(domain_or_name)
547        params = {'DomainName': domain_name,
548                  'ItemName': item_name}
549        if attr_names:
550            if isinstance(attr_names, list):
551                self._build_name_list(params, attr_names)
552            elif isinstance(attr_names, dict) or isinstance(attr_names, self.item_cls):
553                self._build_name_value_list(params, attr_names)
554        if expected_value:
555            self._build_expected_value(params, expected_value)
556        return self.get_status('DeleteAttributes', params)
557
558    def batch_delete_attributes(self, domain_or_name, items):
559        """
560        Delete multiple items in a domain.
561
562        :type domain_or_name: string or :class:`boto.sdb.domain.Domain` object.
563        :param domain_or_name: Either the name of a domain or a Domain object
564
565        :type items: dict or dict-like object
566        :param items: A dictionary-like object.  The keys of the dictionary are
567            the item names and the values are either:
568
569                * dictionaries of attribute names/values, exactly the
570                  same as the attribute_names parameter of the scalar
571                  put_attributes call.  The attribute name/value pairs
572                  will only be deleted if they match the name/value
573                  pairs passed in.
574                * None which means that all attributes associated
575                  with the item should be deleted.
576
577        :return: True if successful
578        """
579        domain, domain_name = self.get_domain_and_name(domain_or_name)
580        params = {'DomainName': domain_name}
581        self._build_batch_list(params, items, False)
582        return self.get_status('BatchDeleteAttributes', params, verb='POST')
583
584    def select(self, domain_or_name, query='', next_token=None,
585               consistent_read=False):
586        """
587        Returns a set of Attributes for item names within domain_name that
588        match the query.  The query must be expressed in using the SELECT
589        style syntax rather than the original SimpleDB query language.
590        Even though the select request does not require a domain object,
591        a domain object must be passed into this method so the Item objects
592        returned can point to the appropriate domain.
593
594        :type domain_or_name: string or :class:`boto.sdb.domain.Domain` object
595        :param domain_or_name: Either the name of a domain or a Domain object
596
597        :type query: string
598        :param query: The SimpleDB query to be performed.
599
600        :type consistent_read: bool
601        :param consistent_read: When set to true, ensures that the most recent
602                                data is returned.
603
604        :rtype: ResultSet
605        :return: An iterator containing the results.
606        """
607        domain, domain_name = self.get_domain_and_name(domain_or_name)
608        params = {'SelectExpression': query}
609        if consistent_read:
610            params['ConsistentRead'] = 'true'
611        if next_token:
612            params['NextToken'] = next_token
613        try:
614            return self.get_list('Select', params, [('Item', self.item_cls)],
615                             parent=domain)
616        except SDBResponseError as e:
617            e.body = "Query: %s\n%s" % (query, e.body)
618            raise e
619