├── .gitignore ├── LICENSE ├── README ├── django └── management │ └── commands │ └── sdb_syncdomains.py ├── fixture.json ├── scripts ├── sdbcopy.py ├── sdbdump.py └── sdbimport.py ├── setup.py ├── simpledb ├── __init__.py ├── models.py └── simpledb.py └── test.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg 3 | *.egg-info 4 | build/ 5 | dist/ 6 | settings.py 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 Six Apart Ltd. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of the organization nor the names of its contributors 15 | may be used to endorse or promote products derived from this software 16 | without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | Python SimpleDB Client and API SDK 2 | ================================== 3 | 4 | Python library for accessing Amazon SimpleDB API Version 2009-04-15. 5 | 6 | Amazon SimpleDB is "a web service providing the core 7 | database functions of data indexing and querying." It is a schemaless persistent data 8 | store that is accessed via a simple HTTP API. 9 | 10 | To get started you'll need to instantiate an instance of the SimpleDB class, passing 11 | in your AWS key and secret (your access identifiers, available at http://aws.amazon.com). 12 | 13 | >>> sdb = SimpleDB(, ) 14 | 15 | Your Amazon SimpleDB account can have zero or more domains, which are discrete partitions 16 | that contain your data. If you have a small, homogenous data set you may choose to use 17 | a single domain. If you have a large data set with varying data types that are easily 18 | partitionable you may choose to use multiple domains, which may improve performance. 19 | 20 | You can create a domain by calling SimpleDB's create_domain method. Note that this 21 | operation may take 10 or more seconds to complete. 22 | 23 | >>> users = sdb.create_domain('users') 24 | 25 | You may access an existing domain by using dictionary syntax on a SimpleDB instance, 26 | which implements a subset of the python dictionary API. You can also use a SimpleDB 27 | instance to iterate over your domains. 28 | 29 | >>> domain1 = sdb['domain1'] 30 | >>> ', '.join(domain.name for domain in sdb) 31 | 'domain1, domain2, foo, test, users' 32 | 33 | SimpleDB Domains are represented by instances of simpledb.Domain. Once again, you 34 | can use dictionary syntax to reference items stored in SimpleDB. 35 | 36 | >>> users = sdb['users'] 37 | >>> users['mmalone'] 38 | {'age': '24', 'name': 'Mike', 'location': 'San Francisco, CA'} 39 | 40 | Item's are represented by instances of simpledb.Item. Note that your data will not 41 | be persisted to SimpleDB until you call the Item's save method. 42 | 43 | >>> user = users['mmalone'] 44 | >>> user['age'] = '25' 45 | >>> user.save() 46 | 47 | You can also assign a dictionary directly to an item in a domain, which will 48 | automatically create and save a simpledb.Item instance. 49 | 50 | >>> users['rcrowley'] = {'name': 'Richard', 'age': '24', 'location': 'San Francisco, CA'} 51 | >>> users['rcrowley'] 52 | {'age': '24', 'name': 'Richard', 'location': 'San Francisco, CA'} 53 | 54 | You can delete items, keys, or entire domains using del. 55 | 56 | >>> del users['rcrowley']['location'] 57 | >>> users['rcrowley'] 58 | {'age': '24', 'name': 'Richard'} 59 | >>> del users['rcrowley'] 60 | >>> users['rcrowley'] 61 | {} 62 | >>> sdb['users'] 63 | 64 | >>> del sdb['users'] 65 | >>> sdb['users']['mmalone'] 66 | Traceback (most recent call last): 67 | ... 68 | File "simpledb.py", line 198, in make_request 69 | raise SimpleDBError(error.find('Message').text) 70 | simpledb.SimpleDBError: The specified domain does not exist. 71 | 72 | 73 | == Querying == 74 | 75 | The simpledb.Domain select operation returns a list of sipmledb.Item instances 76 | that match the select expression. See Amazon's documentation for details re: the 77 | Select expression syntax. 78 | 79 | >>> users.select("SELECT name FROM users WHERE name LIKE 'Kim%'") 80 | [{'name': 'Kim'}, {'name': 'Kimberlee'}, {'name': 'Kimberley'}, {'name': 'Kimberly'}] 81 | 82 | A more Pythonic query interface is available via the filter method. The filter 83 | method returns a simpledb.Query object that lazily evaluates your query. Thus, 84 | you can construct a query incrementally and pass Query objects around in your code 85 | efficiently. 86 | 87 | >>> users.filter(name__like='Kim%').values('name') 88 | [{'name': 'Kim'}, {'name': 'Kimberlee'}, {'name': 'Kimberley'}, {'name': 'Kimberly'}] 89 | 90 | Query's are evaluated when you perform any of the following operations on them: 91 | 92 | * Iteration 93 | * Slicing 94 | * repr() 95 | * len() 96 | * list() 97 | 98 | Most Query methods return a new Query, allowing you to chain operations. The 99 | following methods return a new Query object: 100 | 101 | * filter(*args, **kwargs): Returns a new Query containing objects that match 102 | the given lookup parameters. Attribute lookup syntax is described below. 103 | * values(*attributes): Returns a new Query that restricts the set of attributes 104 | that will be retrieved. 105 | * item_names(): Returns a new Query that returns a list of Item names. 106 | * order_by(attribute): Returns a new Query that has been modified to order 107 | the results by the specified attribute. If the attribute name has a '-' 108 | prefix, the results will be in descending order. 109 | * all(): Returns a copy of the current Query. This is useful if you want to 110 | be able to pass a Domain or a Query object into a function. You can safely 111 | call the all() method on either object. 112 | * count(): Returns a count of the number of objects that match the query. If 113 | the Query has not been evaluated, a COUNT(*) operation is performed on the 114 | database. Otherwise the length of the result set is returned. 115 | 116 | 117 | == Attribute Lookups == 118 | 119 | Attribute lookups are how you construct the WHERE clause of your expression. They're 120 | specified as keyword arguments to the Query method filter() and to simpledb.where, 121 | simpledb.every, and simpledb.item_name clauses, which are described later. 122 | 123 | * EQ: Exact match. If the value provided for comparison is None, it will be 124 | interpreted as NULL. This is the default lookup type, so if you leave the type 125 | off an equals statement will be constructed. 126 | 127 | >>> users.filter(name__eq='Kim') 128 | >>> users.filter(location=None) 129 | 130 | * NOTEQ: The attribute is not equal to the value provided for comparison. If 131 | the value provided for comparison is None, it will be interpreted as NULL. 132 | 133 | >>> users.filter(name__noteq='Mike') 134 | >>> users.filter(location__noteq=None) 135 | 136 | * GT: Greater than. Note that all comparisons are done lexicographically, so 137 | you'll need to pad numbers and offset negatives for proper comparison, and 138 | convert dates to a format that sorts lexicographically like ISO 8601. 139 | 140 | >>> users.filter(age__gt='24') 141 | 142 | * LT: Less than. 143 | 144 | >>> users.filter(age__lt='50') 145 | 146 | * GTE: Greater than or equal to. 147 | * LTE: Less than or equal to. 148 | 149 | * LIKE: Attribute value contains the specified value. The like operator can be 150 | used to evaluate the start of a string ('string%'), the end of the string 151 | ('%string'), or any part of a string ('%string%'). 152 | 153 | >>> users.filter(location__like='San%') 154 | 155 | * NOTLIKE: Attribute value does not contain the specified value. 156 | 157 | * BTWN: Attribute value falls within the specified range. 158 | 159 | >>> users.filter(age__btwn=('20','30')) 160 | 161 | * IN: Attribute value is equal to one of the specified values. 162 | 163 | >>> users.filter(name__in=('Mike', 'Michael')) 164 | 165 | == Advanced Lookups == 166 | 167 | By default, all filter operations are combined with the AND keyword, producing an 168 | increasingly refined result set. If you need to combine multiple where clauses with 169 | an OR connector, you can manually constuct `where` clauses and logically combine them 170 | using the `&` and `|` operators, then pass them directly to the filter method: 171 | 172 | >>> users.filter(simpledb.where(age='24') | simpledb.where(age='25')) 173 | 174 | SimpleDB allows an Attribute to have multiple values, and if any one of the attribute 175 | values matches your query, that attribute will be included in the result set. If you 176 | want _every_ attribute to match your query, you can use an `every` clause: 177 | 178 | >>> users.filter(simpledb.every(location='San Francisco, CA')) 179 | 180 | Finally, you can query items based on their names by using an `item_name` clause. Note 181 | that with item_name clauses the keyword arguments do not include an attribute name, 182 | only the lookup type: 183 | 184 | >>> users.filter(simpledb.item_name(like='%malone')) 185 | >>> users.filter(simpledb.item_name('mmalone')) 186 | >>> users.filter(simpledb.item_name(btwn=('a', 'b'))) 187 | 188 | 189 | == Models == 190 | 191 | Models give you a higher level API for interacting with SimpleDB. Instead of directly 192 | querying the database, you subclass `models.Model` and add one or more `models.Field` 193 | attributes. 194 | 195 | In Python, model fields will appear as ordinary Python objects. When stored in SimpleDB, 196 | the field values are encoded and stored as UTF8 strings. Care is taken to convert the 197 | Python values into strings that are lexicographically sortable and behave sensibly when 198 | querying SimpleDB. 199 | 200 | A Model must have one ItemName attribute. This field's value will be used as the item's 201 | name in SimpleDB. 202 | 203 | A Model must have a Meta class with two attributes: 204 | `connection`: specifies the SimpleDB connection for the Model 205 | `domain`: specifes the domain the Model is stored in 206 | 207 | A simple User model might look like this: 208 | 209 | class User(models.Model): 210 | username = models.ItemName() 211 | name = models.Field() 212 | birth_date = models.DateTimeField() 213 | net_worth = models.NumberField(padding=10, precision=2, offset=1000000000) 214 | 215 | class Meta: 216 | connection = simpledb.SimpleDB(settings.AWS_KEY, settings.AWS_SECRET) 217 | domain = 'new_users' 218 | 219 | You query models via their Manager object which, by default, will be attached to the Model's 220 | `objects` attribute. The Query syntax is the same as that provided by the simpledb 221 | module, but your result set will consist of instances of your Model class, with attribute 222 | values appropriately decoded to Python objects. 223 | 224 | >>> Person.objects.filter(age__gt=20) 225 | >>> Person.objects.get('mmalone') 226 | -------------------------------------------------------------------------------- /django/management/commands/sdb_syncdomains.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand, CommandError 2 | from django.db.models.loading import AppCache 3 | from django.conf import settings 4 | 5 | import simpledb 6 | 7 | class Command(BaseCommand): 8 | help = ("Sync all of the SimpleDB domains.") 9 | 10 | def handle(self, *args, **options): 11 | apps = AppCache() 12 | check = [] 13 | for module in apps.get_apps(): 14 | for d in module.__dict__: 15 | ref = getattr(module, d) 16 | if isinstance(ref, simpledb.models.ModelMetaclass): 17 | domain = ref.Meta.domain.name 18 | if domain not in check: 19 | check.append(domain) 20 | 21 | sdb = simpledb.SimpleDB(settings.AWS_KEY, settings.AWS_SECRET) 22 | domains = [d.name for d in list(sdb)] 23 | for c in check: 24 | if c not in domains: 25 | sdb.create_domain(c) 26 | print "Creating domain %s ..." % c 27 | -------------------------------------------------------------------------------- /fixture.json: -------------------------------------------------------------------------------- 1 | {"test_test": {"snarf": {"snoot": "snarf", "blorp": "3"}, "plop": {"snoot": "heh", "blorp": "4"}}, "test_users": {"fawn": {"age": "16", "name": "Fawn", "location": "San Antonio, ES"}, "augustine": {"age": "70", "name": "Augustine", "location": "\u770c\u7acb\u4e2d\u592e\u8fb2\u696d\u9ad8\u7b49\u5b66\u6821, JP"}, "sonja": {"age": "75", "name": "Sonja", "location": "\u56db\u30c4\u585a\u753a, JP"}, "raymundo": {"age": "20", "name": "Raymundo", "location": "Lester Island, US"}, "lacy": {"age": "90", "name": "Lacy", "location": "\u6a02\u7f8e\u8853\u9928, JP"}, "sherrie": {"age": "39", "name": "Sherrie", "location": "Halorsgrynnan, FI"}, "katie": {"age": "24", "name": "Katie", "location": "San Francisco, CA"}, "zachariah": {"age": "53", "name": "Zachariah", "location": "Strummendorf, DE"}, "marcos": {"age": "52", "name": "Marcos", "location": "Montparnasse, FR"}, "grace": {"age": "65", "name": "Grace", "location": "Highland Park, US"}, "jan": {"age": "66", "name": "Jan", "location": "Pont \u00c0 Bar, FR"}, "trina": {"age": "18", "name": "Trina", "location": "Bonne Fontaine, FR"}, "cheyenne": {"age": "24", "name": "Cheyenne", "location": "Gemeinde Welsebruch, DE"}, "hope": {"age": "15", "name": "Hope", "location": "Maple Springs, US"}, "monica": {"age": "47", "name": "Monica", "location": "\u71e6\u57643C\u548c\u5e73\u6578\u4f4d\u9928, TW"}, "nichole": {"age": "27", "name": "Nichole", "location": "East Ringwood Railway Station, AU"}, "shayla": {"age": "38", "name": "Shayla", "location": "Taman Tanjak, MY"}, "beau": {"age": "6", "name": "Beau", "location": "Emerald Electricals, IN"}, "billie": {"age": "42", "name": "Billie", "location": "Archbishop Murphy High School, US"}, "alisa": {"age": "70", "name": "Alisa", "location": "St.-Jean-d'Eyraud, FR"}, "jermain": {"age": "24", "name": "Jermain", "location": "International School at Dundee, US"}, "teri": {"age": "5", "name": "Teri", "location": "\u00c9quevillon, FR"}, "marian": {"age": "32", "name": "Marian", "location": "Le Marouillet, FR"}, "brody": {"age": "42", "name": "Brody", "location": "\u6cc9\u53f0\u5150\u7ae5\u516c\u5712, JP"}, "ryan": {"age": "23", "name": "Ryan", "location": "MSA Hinesville-Fort Stewart, US"}, "marlin": {"age": "69", "name": "Marlin", "location": "\u5b57\u91d1\u5742\u5f8c, JP"}, "elissa": {"age": "64", "name": "Elissa", "location": "\u79c1\u7acb\u5712\u7530\u5b66\u5712\u9ad8\u7b49\u5b66\u6821, JP"}, "chet": {"age": "15", "name": "Chet", "location": "Cowart Acres, US"}, "shanda": {"age": "88", "name": "Shanda", "location": "Kasat Chemical Private Limited, IN"}, "ted": {"age": "69", "name": "Ted", "location": "Langenhessen, DE"}, "paulette": {"age": "17", "name": "Paulette", "location": "Valukkalotti, IN"}, "sue": {"age": "53", "name": "Sue", "location": "Grogol-Hilir, ID"}, "ernie": {"age": "62", "name": "Ernie", "location": "Kaliyanpur Rani, IN"}, "willis": {"age": "5", "name": "Willis", "location": "8679 Burgkirchen an der Alz, DE"}, "ricky": {"age": "28", "name": "Ricky", "location": "\uc885\ud569\uc6b4\ub3d9\uc7a5, KR"}, "susanne": {"age": "21", "name": "Susanne", "location": "Deepwater Public School, AU"}, "lamar": {"age": "35", "name": "Lamar", "location": "Christiana Elementary School, US"}, "mauricio": {"age": "72", "name": "Mauricio", "location": "\u6fb3\u514b\u5927\u98ef\u5e97, TW"}, "shayna": {"age": "25", "name": "Shayna", "location": "\u6e6f\u5ddd\u516c\u5712, JP"}, "darius": {"age": "71", "name": "Darius", "location": "Fruitwood Park, US"}, "jarrod": {"age": "68", "name": "Jarrod", "location": "Ahipara School, NZ"}, "myron": {"age": "49", "name": "Myron", "location": "01895 Uxbridge, GB"}, "francis": {"age": "87", "name": "Francis", "location": "\u3044\u307e\u3060\u3066\u82b8\u8853\u9928, JP"}, "barrett": {"age": "47", "name": "Barrett", "location": "\u677e\u30f6\u5cf6\u753a, JP"}, "javier": {"age": "59", "name": "Javier", "location": "\ud654\uc131\uc6b0\uccb4\uad6d, KR"}, "jay": {"age": "47", "name": "Jay", "location": "Las Tomilleras, ES"}, "edgar": {"age": "77", "name": "Edgar", "location": "Wyndham Vale, AU"}, "rory": {"age": "20", "name": "Rory", "location": "Concordia College-St. Paul, US"}, "jammie": {"age": "61", "name": "Jammie", "location": "\u65e5\u5411\u5e02\u99c5, JP"}, "rodney": {"age": "76", "name": "Rodney", "location": "Dame-Marie-les-Bois, FR"}, "justen": {"age": "38", "name": "Justen", "location": "Rampur Dubayal, IN"}, "clark": {"age": "9", "name": "Clark", "location": "First hotel Park Astoria, SE"}, "dolores": {"age": "54", "name": "Dolores", "location": "\u5a66\u4e2d\u753a\u677f\u5009, JP"}, "denis": {"age": "88", "name": "Denis", "location": "Puerto Luj\u00e1n, AR"}, "jena": {"age": "22", "name": "Jena", "location": "Comfort Hotel President, SE"}, "darnell": {"age": "90", "name": "Darnell", "location": "Arora Company, IN"}, "deanne": {"age": "85", "name": "Deanne", "location": "Canterbury Village, US"}, "shelly": {"age": "31", "name": "Shelly", "location": "Adalberto Tejeda, MX"}, "roxanne": {"age": "20", "name": "Roxanne", "location": "Oyster Creek Elementary School, US"}, "jeannette": {"age": "56", "name": "Jeannette", "location": "\u5bcc\u6ca2\u7523\u5a66\u4eba\u79d1\u5916\u79d1, JP"}, "daniela": {"age": "22", "name": "Daniela", "location": "\u4e2d\u83ef\u5927\u5b78, TW"}, "gilbert": {"age": "68", "name": "Gilbert", "location": "Feforkampen Fjellhotell, NO"}, "tamera": {"age": "22", "name": "Tamera", "location": "Lorena Elementary School, US"}, "jed": {"age": "85", "name": "Jed", "location": "Skyline Elementary School, US"}, "dion": {"age": "19", "name": "Dion", "location": "Gmina Drawsko Pomorskie, PL"}, "connie": {"age": "88", "name": "Connie", "location": "Rocky Mound, US"}, "iesha": {"age": "41", "name": "Iesha", "location": "Tehsil Greater Noida, IN"}, "josiah": {"age": "64", "name": "Josiah", "location": "\u5b57\u6e05\u6c34\u753a, JP"}, "erick": {"age": "86", "name": "Erick", "location": "Kurubarahalli, IN"}, "britney": {"age": "73", "name": "Britney", "location": "Lakshmipuram, IN"}, "homer": {"age": "8", "name": "Homer", "location": "Keose Glebe, GB"}, "kody": {"age": "30", "name": "Kody", "location": "Alauddins Tomb, IN"}, "carmen": {"age": "50", "name": "Carmen", "location": "\u3069\u3044\u3053\u3069\u3082\u30af\u30ea\u30cb\u30c3\u30af, JP"}, "greggory": {"age": "25", "name": "Greggory", "location": "Jardines de Payo Obispo, MX"}, "rashida": {"age": "84", "name": "Rashida", "location": "Warren Center Preschool, US"}, "latanya": {"age": "73", "name": "Latanya", "location": "Newaygo Airport, US"}, "audrey": {"age": "9", "name": "Audrey", "location": "Brochterbeck, DE"}, "jake": {"age": "1", "name": "Jake", "location": "Stortj\u00e4rnarna, SE"}, "juana": {"age": "84", "name": "Juana", "location": "\u95dc\u83ef\u5c71\u4f11\u9592\u71df\u5340, TW"}, "kenya": {"age": "70", "name": "Kenya", "location": "Santosh Nagar, IN"}, "jenna": {"age": "25", "name": "Jenna", "location": "Frederiksberg, DK"}, "cassie": {"age": "36", "name": "Cassie", "location": "Judith Giacoma Elementary School, US"}, "nikole": {"age": "3", "name": "Nikole", "location": "Price Crossing, US"}, "tomeka": {"age": "52", "name": "Tomeka", "location": "Naz-Sciaves, IT"}, "holli": {"age": "34", "name": "Holli", "location": "Club de Golf Tecumseh, CA"}, "rasheed": {"age": "24", "name": "Rasheed", "location": "Barwa Khurd, IN"}, "dale": {"age": "67", "name": "Dale", "location": "Marau Island, SB"}, "desirae": {"age": "1", "name": "Desirae", "location": "St.-L\u00e9ger-sur-Roanne, FR"}, "francisca": {"age": "69", "name": "Francisca", "location": "Stanthorpe Ambulance Station, AU"}, "stacy": {"age": "76", "name": "Stacy", "location": "Klue\u00df Bahnhof, DE"}, "cullen": {"age": "19", "name": "Cullen", "location": "Klein Oak High School, US"}, "rosanna": {"age": "11", "name": "Rosanna", "location": "Qualcomm Stadium, US"}, "kim": {"age": "67", "name": "Kim", "location": "Summit Lakes Junior High School, US"}, "tyson": {"age": "37", "name": "Tyson", "location": "La Chapelle-St.-Aubin, FR"}, "tanner": {"age": "11", "name": "Tanner", "location": "Mus\u00e9e du Louvre, FR"}, "elaina": {"age": "37", "name": "Elaina", "location": "Boggs Field Park, US"}, "charmaine": {"age": "40", "name": "Charmaine", "location": "Hickory Mountain, US"}, "estevan": {"age": "7", "name": "Estevan", "location": "Sultan Hotel, IN"}, "spencer": {"age": "39", "name": "Spencer", "location": "Broomfield School, NZ"}, "corrie": {"age": "53", "name": "Corrie", "location": "Drachhausen, DE"}}} 2 | -------------------------------------------------------------------------------- /scripts/sdbcopy.py: -------------------------------------------------------------------------------- 1 | import simplejson 2 | from sdbdump import sdbdump 3 | from sdbimport import sdbimport 4 | 5 | def sdbcopy(sdb, from_domain, to_domain): 6 | json = sdbdump(sdb, from_domain) 7 | sdbimport(sdb, to_domain, simplejson.loads(json)) 8 | 9 | if __name__ == '__main__': 10 | import os 11 | import sys 12 | sys.path.insert(1, os.path.normpath(os.path.join(sys.path[0], '..'))) 13 | 14 | import simpledb 15 | import settings 16 | 17 | if len(sys.argv) != 3: 18 | print 'Usage: python sdbcopy.py from_domain to_domain' 19 | sys.exit(1) 20 | 21 | sdb = simpledb.SimpleDB(settings.AWS_KEY, settings.AWS_SECRET) 22 | 23 | print >>sys.stderr, "Copying..." 24 | sdbcopy(sdb, sys.argv[1], sys.argv[2]) 25 | print >>sys.stderr, "All done..." 26 | -------------------------------------------------------------------------------- /scripts/sdbdump.py: -------------------------------------------------------------------------------- 1 | import simplejson 2 | 3 | def sdbdump(sdb, domain): 4 | items = dict((item.name, dict(item)) for item in sdb[domain]) 5 | return simplejson.dumps(items) 6 | 7 | 8 | if __name__ == '__main__': 9 | import os 10 | import sys 11 | sys.path.insert(1, os.path.normpath(os.path.join(sys.path[0], '..'))) 12 | 13 | import simpledb 14 | import settings 15 | 16 | if len(sys.argv) != 2: 17 | print 'Usage: python sdbdump.py ' 18 | sys.exit(1) 19 | 20 | sdb = simpledb.SimpleDB(settings.AWS_KEY, settings.AWS_SECRET) 21 | 22 | print >>sys.stderr, "Dumping..." 23 | print sdbdump(sdb, sys.argv[1]) 24 | print >>sys.stderr, "All done..." 25 | -------------------------------------------------------------------------------- /scripts/sdbimport.py: -------------------------------------------------------------------------------- 1 | import simplejson 2 | 3 | def sdbimport(sdb, domain, items): 4 | 5 | # If the domain doesn't exist, create it. 6 | if not sdb.has_domain(domain): 7 | domain = sdb.create_domain(domain) 8 | else: 9 | domain = sdb[domain] 10 | 11 | # Load the items. 12 | for name, value in items.iteritems(): 13 | domain[name] = value 14 | 15 | 16 | if __name__ == '__main__': 17 | import os 18 | import sys 19 | sys.path.insert(1, os.path.normpath(os.path.join(sys.path[0], '..'))) 20 | 21 | import simpledb 22 | import settings 23 | 24 | if len(sys.argv) != 3: 25 | print 'Usage: python sdbimport.py ' 26 | sys.exit(1) 27 | 28 | 29 | print "Loading..." 30 | items = simplejson.load(open(sys.argv[2])) 31 | 32 | print "Importing..." 33 | sdb = simpledb.SimpleDB(settings.AWS_KEY, settings.AWS_SECRET) 34 | sdbimport(sdb, sys.argv[1], items) 35 | 36 | print "All done." 37 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | from distutils.core import setup 4 | setup( 5 | name='simpledb', 6 | version='1.0', 7 | description='Python SimpleDB API SDK', 8 | long_description = open(os.path.join(os.path.dirname(__file__), 'README')).read(), 9 | author='Michael Malone', 10 | author_email='mjmalone@gmail.com', 11 | url='http://github.com/sixapart/python-simpledb', 12 | 13 | packages=['simpledb'], 14 | provides=['simpledb'], 15 | requires=[ 16 | 'httplib2', 17 | 'elementtree', 18 | ] 19 | ) 20 | -------------------------------------------------------------------------------- /simpledb/__init__.py: -------------------------------------------------------------------------------- 1 | from simpledb import * 2 | import models 3 | 4 | __version__ = '1.0' 5 | __date__ = '3 June 2009' 6 | __author__ = 'Michael Malone' 7 | __credits__ = 'Six Apart Ltd.' 8 | -------------------------------------------------------------------------------- /simpledb/models.py: -------------------------------------------------------------------------------- 1 | import simpledb 2 | import datetime 3 | 4 | 5 | __all__ = ['FieldError', 'Field', 'NumberField', 'BooleanField', 'DateTimeField', 'Manager', 'Model'] 6 | 7 | 8 | class FieldError(Exception): pass 9 | 10 | 11 | class Field(object): 12 | name = False 13 | 14 | def __init__(self, default=None, required=False): 15 | self.default = default 16 | self.required = required 17 | 18 | def install(self, name, cls): 19 | default = self.default 20 | # If the default argument is a callable, call it. 21 | if callable(default): 22 | default = default() 23 | setattr(cls, name, default) 24 | 25 | def decode(self, value): 26 | """Decodes an object from the datastore into a python object.""" 27 | return value 28 | 29 | def encode(self, value): 30 | """Encodes a python object into a value suitable for the backend datastore.""" 31 | return value 32 | 33 | 34 | class ItemName(Field): 35 | """The item's name. Must be a UTF8 string.""" 36 | name = True 37 | 38 | 39 | class NumberField(Field): 40 | def __init__(self, padding=0, offset=0, precision=0, **kwargs): 41 | self.padding = padding 42 | self.offset = offset 43 | self.precision = precision 44 | super(NumberField, self).__init__(**kwargs) 45 | 46 | def encode(self, value): 47 | """ 48 | Converts a python number into a padded string that is suitable for storage 49 | in Amazon SimpleDB and can be sorted lexicographically. 50 | 51 | Numbers are shifted by an offset so that negative numbers sort correctly. Once 52 | shifted, they are converted to zero padded strings. 53 | """ 54 | padding = self.padding 55 | if self.precision > 0 and self.padding > 0: 56 | # Padding shouldn't include decimal digits or the decimal point. 57 | padding += self.precision + 1 58 | return ('%%0%d.%df' % (padding, self.precision)) % (value + self.offset) 59 | 60 | def decode(self, value): 61 | """ 62 | Decoding converts a string into a numerical type then shifts it by the 63 | offset. 64 | """ 65 | return float(value) - self.offset 66 | 67 | 68 | class BooleanField(Field): 69 | def encode(self, value): 70 | """ 71 | Converts a python boolean into a string '1'/'0' for storage in SimpleDB. 72 | """ 73 | return ('0','1')[value] 74 | 75 | def decode(self, value): 76 | """ 77 | Converts an encoded string '1'/'0' into a python boolean object. 78 | """ 79 | return {'0': False, '1': True}[value] 80 | 81 | 82 | class DateTimeField(Field): 83 | def __init__(self, format='%Y-%m-%dT%H:%M:%S', **kwargs): 84 | self.format = format 85 | super(DateTimeField, self).__init__(**kwargs) 86 | 87 | def encode(self, value): 88 | """ 89 | Converts a python datetime object to a string format controlled by the 90 | `format` attribute. The default format is ISO 8601, which supports 91 | lexicographical order comparisons. 92 | """ 93 | return value.strftime(self.format) 94 | 95 | def decode(self, value): 96 | """ 97 | Decodes a string representation of a date and time into a python 98 | datetime object. 99 | """ 100 | return datetime.datetime.strptime(value, self.format) 101 | 102 | 103 | class FieldEncoder(simpledb.AttributeEncoder): 104 | def __init__(self, fields): 105 | self.fields = fields 106 | 107 | def encode(self, domain, attribute, value): 108 | try: 109 | field = self.fields[attribute] 110 | except KeyError: 111 | return value 112 | else: 113 | return field.encode(value) 114 | 115 | def decode(self, domain, attribute, value): 116 | try: 117 | field = self.fields[attribute] 118 | except KeyError: 119 | return value 120 | else: 121 | return field.decode(value) 122 | 123 | 124 | class Query(simpledb.Query): 125 | def values(self, *fields): 126 | # If you ask for specific values return a simpledb.Item instead of the Model 127 | q = self._clone(klass=simpledb.Query) 128 | q.fields = fields 129 | return q 130 | 131 | def _get_results(self): 132 | if self._result_cache is None: 133 | self._result_cache = [self.domain.model.from_item(item) for item in 134 | self.domain.select(self.to_expression())] 135 | return self._result_cache 136 | 137 | 138 | class Manager(object): 139 | # Tracks each time a Manager instance is created. Used to retain order. 140 | creation_counter = 0 141 | 142 | def __init__(self): 143 | self._set_creation_counter() 144 | self.model = None 145 | 146 | def install(self, name, model): 147 | self.model = model 148 | setattr(model, name, ManagerDescriptor(self)) 149 | if not getattr(model, '_default_manager', None) or self.creation_counter < model._default_manager.creation_counter: 150 | model._default_manager = self 151 | 152 | def _set_creation_counter(self): 153 | """ 154 | Sets the creation counter value for this instance and increments the 155 | class-level copy. 156 | """ 157 | self.creation_counter = Manager.creation_counter 158 | Manager.creation_counter += 1 159 | 160 | def filter(self, *args, **kwargs): 161 | return self._get_query().filter(*args, **kwargs) 162 | 163 | def all(self): 164 | return self._get_query() 165 | 166 | def count(self): 167 | return self._get_query().count() 168 | 169 | def values(self, *args): 170 | return self._get_query().values(*args) 171 | 172 | def item_names(self): 173 | return self._get_query().item_names() 174 | 175 | def get(self, name): 176 | return self.model.from_item(self.model.Meta.domain.get(name)) 177 | 178 | def _get_query(self): 179 | return Query(self.model.Meta.domain) 180 | 181 | 182 | class ManagerDescriptor(object): 183 | # This class ensures managers aren't accessible via model instances. 184 | # For example, Poll.objects works, but poll_obj.objects raises AttributeError. 185 | def __init__(self, manager): 186 | self.manager = manager 187 | 188 | def __get__(self, instance, type=None): 189 | if instance != None: 190 | raise AttributeError("Manager isn't accessible via %s instances" % type.__name__) 191 | return self.manager 192 | 193 | 194 | class ModelMetaclass(type): 195 | """ 196 | Metaclass for `simpledb.models.Model` instances. Installs 197 | `simpledb.models.Field` instances declared as attributes of the 198 | new class. 199 | """ 200 | 201 | def __new__(cls, name, bases, attrs): 202 | parents = [b for b in bases if isinstance(b, ModelMetaclass)] 203 | if not parents: 204 | # If this isn't a subclass of Model, don't do anything special. 205 | return super(ModelMetaclass, cls).__new__(cls, name, bases, attrs) 206 | fields = {} 207 | 208 | for base in bases: 209 | if isinstance(base, ModelMetaclass) and hasattr(base, 'fields'): 210 | fields.update(base.fields) 211 | 212 | new_fields = {} 213 | managers = {} 214 | 215 | # Move all the class's attributes that are Fields to the fields set. 216 | for attrname, field in attrs.items(): 217 | if isinstance(field, Field): 218 | new_fields[attrname] = field 219 | if field.name: 220 | # Add _name_field attr so we know what the key is 221 | if '_name_field' in attrs: 222 | raise FieldError("Multiple key fields defined for model '%s'" % name) 223 | attrs['_name_field'] = attrname 224 | elif attrname in fields: 225 | # Throw out any parent fields that the subclass defined as 226 | # something other than a field 227 | del fields[attrname] 228 | 229 | # Track managers 230 | if isinstance(field, Manager): 231 | managers[attrname] = field 232 | 233 | fields.update(new_fields) 234 | attrs['fields'] = fields 235 | new_cls = super(ModelMetaclass, cls).__new__(cls, name, bases, attrs) 236 | 237 | for field, value in new_fields.items(): 238 | new_cls.add_to_class(field, value) 239 | 240 | if not managers: 241 | managers['objects'] = Manager() 242 | 243 | for field, value in managers.items(): 244 | new_cls.add_to_class(field, value) 245 | 246 | if hasattr(new_cls, 'Meta'): 247 | # If the new class's Meta.domain attribute is a string turn it into 248 | # a simpledb.Domain instance. 249 | if isinstance(new_cls.Meta.domain, basestring): 250 | new_cls.Meta.domain = simpledb.Domain(new_cls.Meta.domain, new_cls.Meta.connection) 251 | # Install a reference to the new model class on the Meta.domain so 252 | # Query can use it. 253 | # TODO: Should we be using weakref here? Not sure it matters since it's 254 | # a class (global) that's long lived anyways. 255 | new_cls.Meta.domain.model = new_cls 256 | 257 | # Set the connection object's AttributeEncoder 258 | new_cls.Meta.connection.encoder = FieldEncoder(fields) 259 | 260 | return new_cls 261 | 262 | def add_to_class(cls, name, value): 263 | if hasattr(value, 'install'): 264 | value.install(name, cls) 265 | else: 266 | setattr(cls, name, value) 267 | 268 | 269 | class Model(object): 270 | 271 | __metaclass__ = ModelMetaclass 272 | 273 | def __init__(self, **kwargs): 274 | for name, value in kwargs.items(): 275 | setattr(self, name, value) 276 | self._item = None 277 | 278 | def _get_name(self): 279 | return getattr(self, self._name_field) 280 | 281 | def save(self): 282 | if self._item is None: 283 | self._item = simpledb.Item(self.Meta.connection, self.Meta.domain, self._get_name()) 284 | for name, field in self.fields.items(): 285 | if field.name: 286 | continue 287 | value = getattr(self, name) 288 | if value is None: 289 | if field.required: 290 | raise FieldError("Missing required field '%s'" % name) 291 | else: 292 | del self._item[name] 293 | continue 294 | self._item[name] = getattr(self, name) 295 | self._item.save() 296 | 297 | def delete(self): 298 | del self.Meta.domain[self._get_name()] 299 | 300 | @classmethod 301 | def from_item(cls, item): 302 | obj = cls() 303 | obj._item = item 304 | for name, field in obj.fields.items(): 305 | if name in obj._item: 306 | setattr(obj, name, obj._item[name]) 307 | setattr(obj, obj._name_field, obj._item.name) 308 | return obj 309 | -------------------------------------------------------------------------------- /simpledb/simpledb.py: -------------------------------------------------------------------------------- 1 | import httplib2 2 | import urlparse 3 | import urllib 4 | import time 5 | import hmac 6 | import base64 7 | try: 8 | import xml.etree.ElementTree as ET 9 | except ImportError: 10 | import elementtree.ElementTree as ET 11 | from UserDict import DictMixin 12 | 13 | 14 | __all__ = ['SimpleDB', 'Domain', 'Item', 'AttributeEncoder', 'where', 'every', 'item_name', 'SimpleDBError', 'ItemDoesNotExist'] 15 | 16 | 17 | QUERY_OPERATORS = { 18 | # Note that `is null`, `is not null` and `every` are handled specially by using 19 | # attr__eq = None, attr__noteq = None, and every(), respectively. 20 | 'eq': '=', # equals 21 | 'noteq': '!=', # not equals 22 | 'gt': '>', # greather than 23 | 'gte': '>=', # greater than or equals 24 | 'lt': '<', # less than 25 | 'lte': '<=', # less than or equals 26 | 'like': 'like', # contains, works with `%` globs: '%string' or 'string%' 27 | 'notlike': 'not like', # doesn't contain 28 | 'btwn': 'between', # falls within range (inclusive) 29 | 'in': 'in', # equal to one of 30 | } 31 | 32 | 33 | RESERVED_KEYWORDS = ( 34 | 'OR', 'AND', 'NOT', 'FROM', 'WHERE', 'SELECT', 'LIKE', 'NULL', 'IS', 'ORDER', 35 | 'BY', 'ASC', 'DESC', 'IN', 'BETWEEN', 'INTERSECTION', 'LIMIT', 'EVERY', 36 | ) 37 | 38 | 39 | class SimpleDBError(Exception): pass 40 | class ItemDoesNotExist(Exception): pass 41 | 42 | 43 | def generate_timestamp(): 44 | return time.strftime('%Y-%m-%dT%H:%M:%S', time.gmtime()) 45 | 46 | 47 | def _utf8_str(s): 48 | if isinstance(s, unicode): 49 | return s.encode('utf-8') 50 | else: 51 | return str(s) 52 | 53 | 54 | def escape(s): 55 | return urllib.quote(s, safe='-_~') 56 | 57 | 58 | def urlencode(d): 59 | if isinstance(d, dict): 60 | d = d.iteritems() 61 | return '&'.join(['%s=%s' % (escape(k), escape(v)) for k, v in d]) 62 | 63 | 64 | class SignatureMethod(object): 65 | 66 | @property 67 | def name(self): 68 | raise NotImplementedError 69 | 70 | def build_signature_base_string(self, request): 71 | sig = '\n'.join(( 72 | request.get_normalized_http_method(), 73 | request.get_normalized_http_host(), 74 | request.get_normalized_http_path(), 75 | request.get_normalized_parameters(), 76 | )) 77 | return sig 78 | 79 | def build_signature(self, request, aws_secret): 80 | raise NotImplementedError 81 | 82 | 83 | class SignatureMethod_HMAC_SHA1(SignatureMethod): 84 | name = 'HmacSHA1' 85 | version = '2' 86 | 87 | def build_signature(self, request, aws_secret): 88 | base = self.build_signature_base_string(request) 89 | try: 90 | import hashlib # 2.5 91 | hashed = hmac.new(aws_secret, base, hashlib.sha1) 92 | except ImportError: 93 | import sha # deprecated 94 | hashed = hmac.new(aws_secret, base, sha) 95 | return base64.b64encode(hashed.digest()) 96 | 97 | 98 | class SignatureMethod_HMAC_SHA256(SignatureMethod): 99 | name = 'HmacSHA256' 100 | version = '2' 101 | 102 | def build_signature(self, request, aws_secret): 103 | import hashlib 104 | base = self.build_signature_base_string(request) 105 | hashed = hmac.new(aws_secret, base, hashlib.sha256) 106 | return base64.b64encode(hashed.digest()) 107 | 108 | 109 | class Response(object): 110 | def __init__(self, response, content, request_id, usage): 111 | self.response = response 112 | self.content = content 113 | self.request_id = request_id 114 | self.usage = usage 115 | 116 | 117 | class Request(object): 118 | def __init__(self, method, url, parameters=None): 119 | self.method = method 120 | self.url = url 121 | self.parameters = parameters or {} 122 | 123 | def set_parameter(self, name, value): 124 | self.parameters[name] = value 125 | 126 | def get_parameter(self, parameter): 127 | try: 128 | return self.parameters[parameter] 129 | except KeyError: 130 | raise SimpleDBError('Parameter not found: %s' % parameter) 131 | 132 | def to_postdata(self): 133 | return urlencode([(_utf8_str(k), _utf8_str(v)) for k, v in self.parameters.iteritems()]) 134 | 135 | def get_normalized_parameters(self): 136 | """ 137 | Returns a list constisting of all the parameters required in the 138 | signature in the proper order. 139 | 140 | """ 141 | return urlencode([(_utf8_str(k), _utf8_str(v)) for k, v in 142 | sorted(self.parameters.iteritems()) 143 | if k != 'Signature']) 144 | 145 | def get_normalized_http_method(self): 146 | return self.method.upper() 147 | 148 | def get_normalized_http_path(self): 149 | parts = urlparse.urlparse(self.url) 150 | if not parts[2]: 151 | # For an empty path use '/' 152 | return '/' 153 | return parts[2] 154 | 155 | def get_normalized_http_host(self): 156 | parts = urlparse.urlparse(self.url) 157 | return parts[1].lower() 158 | 159 | def sign_request(self, signature_method, aws_key, aws_secret): 160 | self.set_parameter('AWSAccessKeyId', aws_key) 161 | self.set_parameter('SignatureVersion', signature_method.version) 162 | self.set_parameter('SignatureMethod', signature_method.name) 163 | self.set_parameter('Timestamp', generate_timestamp()) 164 | self.set_parameter('Signature', signature_method.build_signature(self, aws_secret)) 165 | 166 | 167 | class AttributeEncoder(object): 168 | """ 169 | AttributeEncoder converts Python objects into UTF8 strings suitable for 170 | storage in SimpleDB. 171 | """ 172 | 173 | def encode(self, domain, attribute, value): 174 | return value 175 | 176 | def decode(self, domain, attribute, value): 177 | return value 178 | 179 | 180 | class NumberEncoder(object): 181 | def encode(self, domain, attribute, value): 182 | if isinstance(value, int): 183 | return str(value + 10000) 184 | return value 185 | 186 | def decode(self, domain, attribute, value): 187 | if value.isdigit(): 188 | return int(value) - 10000 189 | return value 190 | 191 | 192 | class SimpleDB(object): 193 | """Represents a connection to Amazon SimpleDB.""" 194 | 195 | ns = 'http://sdb.amazonaws.com/doc/2009-04-15/' 196 | service_version = '2009-04-15' 197 | try: 198 | import hashlib # 2.5+ 199 | signature_method = SignatureMethod_HMAC_SHA256 200 | except ImportError: 201 | signature_method = SignatureMethod_HMAC_SHA1 202 | 203 | 204 | def __init__(self, aws_access_key, aws_secret_access_key, db='sdb.amazonaws.com', 205 | secure=True, encoder=AttributeEncoder()): 206 | """ 207 | Use your `aws_access_key` and `aws_secret_access_key` to create a connection to 208 | Amazon SimpleDB. 209 | 210 | SimpleDB requests are directed to the host specified by `db`, which defaults to 211 | ``sdb.amazonaws.com``. 212 | 213 | The optional `secure` argument specifies whether HTTPS should be used. The 214 | default value is ``True``. 215 | """ 216 | 217 | self.aws_key = aws_access_key 218 | self.aws_secret = aws_secret_access_key 219 | if secure: 220 | self.scheme = 'https' 221 | else: 222 | self.scheme = 'http' 223 | self.db = db 224 | self.http = httplib2.Http() 225 | self.encoder = encoder 226 | 227 | def _make_request(self, request): 228 | headers = {'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', 229 | 'host': self.db} 230 | request.set_parameter('Version', self.service_version) 231 | request.sign_request(self.signature_method(), self.aws_key, self.aws_secret) 232 | response, content = self.http.request(request.url, request.method, headers=headers, body=request.to_postdata()) 233 | e = ET.fromstring(content) 234 | 235 | error = e.find('Errors/Error') 236 | if error: 237 | raise SimpleDBError(error.find('Message').text) 238 | 239 | meta = e.find('{%s}ResponseMetadata' % self.ns) 240 | request_id = meta.find('{%s}RequestId' % self.ns).text 241 | usage = meta.find('{%s}BoxUsage' % self.ns).text 242 | 243 | return Response(response, content, request_id, usage) 244 | 245 | def _sdb_url(self): 246 | return urlparse.urlunparse((self.scheme, self.db, '', '', '', '')) 247 | 248 | def create_domain(self, name): 249 | """ 250 | Creates a new domain. 251 | 252 | The domain `name` argument must be a string, and must be unique among 253 | the domains associated with your AWS Access Key. The CreateDomain operation 254 | may take 10 or more seconds to complete. By default, you can create up to 255 | 100 domains per account. 256 | 257 | Returns the newly created `Domain` object. 258 | """ 259 | 260 | data = { 261 | 'Action': 'CreateDomain', 262 | 'DomainName': name, 263 | } 264 | request = Request("POST", self._sdb_url(), data) 265 | self._make_request(request) 266 | return Domain(name, self) 267 | 268 | def delete_domain(self, domain): 269 | """ 270 | Deletes a domain. Any items (and their attributes) in the domain are 271 | deleted as well. The DeleteDomain operation may take 10 or more seconds 272 | to complete. 273 | 274 | The `domain` argument can be a string representing the name of the 275 | domain, or a `Domain` object. 276 | """ 277 | 278 | if isinstance(domain, Domain): 279 | domain = domain.name 280 | data = { 281 | 'Action': 'DeleteDomain', 282 | 'DomainName': domain, 283 | } 284 | request = Request("POST", self._sdb_url(), data) 285 | self._make_request(request) 286 | 287 | def _list_domains(self): 288 | # Generator that yields each domain associated with the AWS Access Key. 289 | data = { 290 | 'Action': 'ListDomains', 291 | 'MaxNumberOfDomains': '100', 292 | } 293 | 294 | while True: 295 | request = Request("POST", self._sdb_url(), data) 296 | response = self._make_request(request) 297 | 298 | e = ET.fromstring(response.content) 299 | domain_result = e.find('{%s}ListDomainsResult' % self.ns) 300 | if domain_result: 301 | domain_names = domain_result.findall('{%s}DomainName' % self.ns) 302 | for domain in domain_names: 303 | yield Domain(domain.text, self) 304 | 305 | # SimpleDB will return a max of 100 domains per request, and 306 | # will return a NextToken if there are more. 307 | next_token = domain_result.find('{%s}NextToken' % self.ns) 308 | if next_token is None: 309 | break 310 | data['NextToken'] = next_token.text 311 | else: 312 | break 313 | 314 | def list_domains(self): 315 | """ 316 | Lists all domains associated with your AWS Access Key. 317 | """ 318 | return list(self._list_domains()) 319 | 320 | def has_domain(self, domain): 321 | if isinstance(domain, Domain): 322 | domain = domain.name 323 | return domain in [d.name for d in self.list_domains()] 324 | 325 | def get_domain_metadata(self, domain): 326 | """ 327 | Returns information about the domain. Includes when the domain was 328 | created, the number of items and attributes, and the size of attribute 329 | names and values. 330 | 331 | The `domain` argument can be a string representing the name of the 332 | domain or a `Domain` object. 333 | """ 334 | if isinstance(domain, Domain): 335 | domain = domain.name 336 | data = { 337 | 'Action': 'DomainMetadata', 338 | 'DomainName': domain, 339 | } 340 | request = Request("POST", self._sdb_url(), data) 341 | response = self._make_request(request) 342 | 343 | e = ET.fromstring(response.content) 344 | metadata = {} 345 | metadata_result = e.find('{%s}DomainMetadataResult' % self.ns) 346 | if metadata_result is not None: 347 | for child in metadata_result.getchildren(): 348 | tag, text = child.tag, child.text 349 | if tag.startswith('{%s}' % self.ns): 350 | tag = tag[42:] # Die ElementTree namespaces, die! 351 | metadata[tag] = text 352 | return metadata 353 | 354 | def put_attributes(self, domain, item, attributes): 355 | """ 356 | Creates or replaces attributes in an item. 357 | 358 | The `domain` and `item` arguments can be strings representing the 359 | domain and item names, or `Domain` and `Item` objects, respectively. 360 | 361 | The `attributes` argument should be a dictionary containing the 362 | attribute names -> values that you would like stored for the 363 | specified `item` or a list of (, , ) 364 | tuples. 365 | 366 | By default, attributes are "replaced". This causes new attribute values to 367 | overwrite existing values. For example, if an item has the attributes 368 | ('a', '1'), ('b', '2') and ('b', '3') and you call put_attributes using 369 | the attributes ('b', '4'), the final attributes of the item are changed to 370 | ('a', '1') and ('b', '4'), which replaces the previous value of the 'b' 371 | attribute with the new value. 372 | 373 | SimpleDB allows you to associate multiple values with a single attribute. 374 | If an attribute has multiple values, it will be coalesced into a single 375 | list. Likewise, if you'd like to store multiple values for a single 376 | attribute, you should pass in a list value to this method. 377 | """ 378 | 379 | if isinstance(domain, Domain): 380 | domain = domain.name 381 | if isinstance(item, Item): 382 | item = item.name 383 | if hasattr(attributes, 'items'): 384 | # Normalize attributes into a list of tuples. 385 | attributes = attributes.items() 386 | 387 | data = { 388 | 'Action': 'PutAttributes', 389 | 'DomainName': domain, 390 | 'ItemName': item, 391 | } 392 | idx = 0 393 | for attribute in attributes: 394 | name = attribute[0] 395 | values = attribute[1] 396 | if not hasattr(values, '__iter__') or isinstance(values, basestring): 397 | values = [values] 398 | for value in values: 399 | value = self.encoder.encode(domain, name, value) 400 | data['Attribute.%s.Name' % idx] = name 401 | data['Attribute.%s.Value' % idx] = value 402 | if len(attribute) == 2 or attribute[2]: 403 | data['Attribute.%s.Replace' % idx] = 'true' 404 | idx += 1 405 | request = Request("POST", self._sdb_url(), data) 406 | self._make_request(request) 407 | 408 | def batch_put_attributes(self, domain, items, replace=True): 409 | """ 410 | Performs multiple PutAttribute operations in a single call. This yields 411 | savings in round trips and latencies and enables SimpleDB to optimize 412 | your request, which generally yields better throughput. 413 | 414 | The `domain` argument can be a string representing the name of the 415 | domain or a `Domain` object. 416 | 417 | The `items` argument should be a list of `Item` objects or a list of 418 | (, ) tuples. See the documentation for the 419 | put_attribute method for a description of how attributes should be 420 | represented. 421 | """ 422 | 423 | if isinstance(domain, Domain): 424 | domain = domain.name 425 | 426 | data = { 427 | 'Action': 'BatchPutAttributes', 428 | 'DomainName': domain, 429 | } 430 | for item_idx, item in enumerate(items): 431 | if isinstance(item, Item): 432 | item = [item.name, item.attributes] 433 | item = list(item) 434 | if hasattr(item[1], 'items'): 435 | # Normalize attributes into a list of tuples. 436 | item[1] = item[1].items() 437 | 438 | data['Item.%s.ItemName' % item_idx] = item[0] 439 | attr_idx = 0 440 | for attribute in item[1]: 441 | name = attribute[0] 442 | values = attribute[1] 443 | if isinstance(values, basestring): 444 | values = [values] 445 | for value in values: 446 | value = self.encoder.encode(domain, name, value) 447 | data['Item.%s.Attribute.%s.Name' % (item_idx, attr_idx)] = name 448 | data['Item.%s.Attribute.%s.Value' % (item_idx, attr_idx)] = value 449 | if len(attribute) == 2 or attribute[2]: 450 | data['Item.%s.Attribute.%s.Replace' % (item_idx, attr_idx)] = 'true' 451 | attr_idx += 1 452 | request = Request("POST", self._sdb_url(), data) 453 | self._make_request(request) 454 | 455 | def delete_attributes(self, domain, item, attributes=None): 456 | """ 457 | Deletes one or more attributes associated with an item. If all attributes of 458 | an item are deleted, the item is deleted. 459 | 460 | If the optional parameter `attributes` is not provided, all items are deleted. 461 | """ 462 | if isinstance(domain, Domain): 463 | domain = domain.name 464 | if isinstance(item, Item): 465 | item = item.name 466 | if attributes is None: 467 | attributes = {} 468 | 469 | data = { 470 | 'Action': 'DeleteAttributes', 471 | 'DomainName': domain, 472 | 'ItemName': item, 473 | } 474 | for i, (name, value) in enumerate(attributes.iteritems()): 475 | value = self.encoder.encode(domain, name, value) 476 | data['Attribute.%s.Name' % i] = name 477 | data['Attribute.%s.Value' % i] = value 478 | request = Request("POST", self._sdb_url(), data) 479 | self._make_request(request) 480 | 481 | def get_attributes(self, domain, item, attributes=None): 482 | """ 483 | Returns all of the attributes associated with the item. 484 | 485 | The returned attributes can be limited by passing a list of attribute 486 | names in the optional `attributes` argument. 487 | 488 | If the item does not exist, an empty set is returned. An error is not 489 | raised because SimpleDB provides no guarantee that the item does not 490 | exist on another replica. In other words, if you fetch attributes that 491 | should exist, but get an empty set, you may have better luck if you try 492 | again in a few hundred milliseconds. 493 | """ 494 | if isinstance(domain, Domain): 495 | domain = domain.name 496 | if isinstance(item, Item): 497 | item = item.name 498 | 499 | data = { 500 | 'Action': 'GetAttributes', 501 | 'DomainName': domain, 502 | 'ItemName': item, 503 | } 504 | if attributes: 505 | for i, attr in enumerate(attributes): 506 | data['AttributeName.%s' % i] = attr 507 | request = Request("POST", self._sdb_url(), data) 508 | response = self._make_request(request) 509 | 510 | e = ET.fromstring(response.content) 511 | attributes = dict.fromkeys(attributes or []) 512 | attr_node = e.find('{%s}GetAttributesResult' % self.ns) 513 | if attr_node: 514 | attributes.update(self._parse_attributes(domain, attr_node)) 515 | return attributes 516 | 517 | def _parse_attributes(self, domain, attribute_node): 518 | # attribute_node should be an ElementTree node containing Attribute 519 | # child elements. 520 | attributes = {} 521 | for attribute in attribute_node.findall('{%s}Attribute' % self.ns): 522 | name = attribute.find('{%s}Name' % self.ns).text 523 | value = attribute.find('{%s}Value' % self.ns).text 524 | value = self.encoder.decode(domain, name, value) 525 | if name in attributes: 526 | if isinstance(attributes[name], list): 527 | attributes[name].append(value) 528 | else: 529 | attributes[name] = [attributes[name], value] 530 | else: 531 | attributes[name] = value 532 | return attributes 533 | 534 | def _select(self, domain, expression): 535 | if not isinstance(domain, Domain): 536 | domain = Domain(domain, self) 537 | data = { 538 | 'Action': 'Select', 539 | 'SelectExpression': expression, 540 | } 541 | 542 | while True: 543 | request = Request("POST", self._sdb_url(), data) 544 | response = self._make_request(request) 545 | 546 | e = ET.fromstring(response.content) 547 | item_node = e.find('{%s}SelectResult' % self.ns) 548 | if item_node is not None: 549 | for item in item_node.findall('{%s}Item' % self.ns): 550 | name = item.findtext('{%s}Name' % self.ns) 551 | attributes = self._parse_attributes(domain, item) 552 | yield Item(self, domain, name, attributes) 553 | 554 | # SimpleDB will return a max of 100 items per request, and 555 | # will return a NextToken if there are more. 556 | next_token = item_node.find('{%s}NextToken' % self.ns) 557 | if next_token is None: 558 | break 559 | data['NextToken'] = next_token.text 560 | else: 561 | break 562 | 563 | def select(self, domain, expression): 564 | return list(self._select(domain, expression)) 565 | 566 | def __iter__(self): 567 | return self._list_domains() 568 | 569 | def __getitem__(self, name): 570 | # TODO: Check if it's a valid domain 571 | return Domain(name, self) 572 | 573 | def __delitem__(self, name): 574 | self.delete_domain(name) 575 | 576 | 577 | class where(object): 578 | """ 579 | Encapsulate where clause as objects that can be combined logically using 580 | & and |. 581 | """ 582 | 583 | # Connection types 584 | AND = 'AND' 585 | OR = 'OR' 586 | default = AND 587 | 588 | def __init__(self, *args, **query): 589 | self.connector = self.default 590 | self.children = [] 591 | self.children.extend(args) 592 | for key, value in query.iteritems(): 593 | if '__' in key: 594 | parts = key.split('__') 595 | if len(parts) != 2: 596 | raise ValueError("Filter arguments should be of the form " 597 | "`field__operation`") 598 | field, operation = parts 599 | else: 600 | field, operation = key, 'eq' 601 | 602 | if operation not in QUERY_OPERATORS: 603 | raise ValueError('%s is not a valid query operation' % (operation,)) 604 | self.children.append((field, operation, value)) 605 | 606 | def __len__(self): 607 | return len(self.children) 608 | 609 | def to_expression(self, encoder): 610 | """ 611 | Returns the query expression for the where clause. Returns an empty 612 | string if the node is empty. 613 | """ 614 | where = [] 615 | for child in self.children: 616 | if hasattr(child, 'to_expression'): 617 | expr = child.to_expression(encoder) 618 | if expr: 619 | where.append('(%s)' % expr) 620 | else: 621 | field, operation, value = child 622 | operator = QUERY_OPERATORS[operation] 623 | if hasattr(self, '_make_%s_condition' % operation): 624 | expr = getattr(self, '_make_%s_condition' % operation)(field, operator, value, encoder) 625 | else: 626 | expr = self._make_condition(field, operator, value, encoder) 627 | where.append(expr) 628 | conn_str = ' %s ' % self.connector 629 | return conn_str.join(where) 630 | 631 | def add(self, other, conn): 632 | """ 633 | Adds a new clause to the where statement. If the connector type is the 634 | same as the root's current connector type, the clause is added to the 635 | first level. Otherwise, the whole tree is pushed down one level and a 636 | new root connector is created, connecting the existing clauses and the 637 | new clause. 638 | """ 639 | if other in self.children and conn == self.connector: 640 | return 641 | if len(self.children) < 2: 642 | self.connector = conn 643 | if self.connector == conn: 644 | if isinstance(other, where) and (other.connector == conn or 645 | len(other) <= 1): 646 | self.children.extend(other.children) 647 | else: 648 | self.children.append(other) 649 | else: 650 | obj = self._clone() 651 | self.connector = conn 652 | self.children = [obj, other] 653 | 654 | def _make_condition(self, attribute, operation, value, encoder): 655 | value = encoder(attribute, value) 656 | return "%s %s '%s'" % (self._quote_attribute(attribute), 657 | operation, self._quote(value)) 658 | 659 | def _make_eq_condition(self, attribute, operation, value, encoder): 660 | value = encoder(attribute, value) 661 | if value is None: 662 | return '%s IS NULL' % attribute 663 | return self._make_condition(attribute, operation, value, encoder) 664 | 665 | def _make_noteq_condition(self, attribute, operation, value, encoder): 666 | value = encoder(attribute, value) 667 | if value is None: 668 | return '%s IS NOT NULL' % attribute 669 | return self._make_condition(attribute, operation, value, encoder) 670 | 671 | def _make_in_condition(self, attribute, operation, value, encoder): 672 | value = [encoder(attribute, v) for v in value] 673 | return '%s %s(%s)' % (attribute, operation, 674 | ', '.join("'%s'" % self._quote(v) for v in value)) 675 | 676 | def _make_btwn_condition(self, attribute, operation, value, encoder): 677 | if len(value) != 2: 678 | raise ValueError('Invalid value `%s` for between clause. Requires two item list.' % value) 679 | value = [encoder(attribute, value[0]), encoder(attribute, value[1])] 680 | return "%s between '%s' and '%s'" % (attribute, self._quote(value[0]), self._quote(value[1])) 681 | 682 | def _quote_attribute(self, s): 683 | if s.upper() in RESERVED_KEYWORDS: 684 | return '`%s`' % s 685 | return s 686 | 687 | def _quote(self, s): 688 | return s.replace('\'', '\'\'') 689 | 690 | def _clone(self, klass=None, **kwargs): 691 | if klass is None: 692 | klass = self.__class__ 693 | obj = klass() 694 | obj.connector = self.connector 695 | obj.children = self.children[:] 696 | return obj 697 | 698 | def _combine(self, other, conn): 699 | if not isinstance(other, where): 700 | raise TypeError(other) 701 | obj = self._clone() 702 | obj.add(other, conn) 703 | return obj 704 | 705 | def __or__(self, other): 706 | return self._combine(other, self.OR) 707 | 708 | def __and__(self, other): 709 | return self._combine(other, self.AND) 710 | 711 | 712 | class every(where): 713 | """ 714 | Encapsulates a where clause and uses the every() operator which, 715 | for multi-valued attributes, checks that every attribute satisfies 716 | the constraint. 717 | """ 718 | def _every(self, attribute): 719 | return "every(%s)" % self._quote_attribute(attribute) 720 | 721 | def _make_condition(self, attribute, operation, value, encoder): 722 | return super(every, self)._make_condition(self._every(attribute), operation, value, encoder) 723 | 724 | def _make_eq_condition(self, attribute, operation, value, encoder): 725 | if value is None: 726 | attribute = self._every(attribute) 727 | return super(every, self)._make_eq_condition(attribute, operation, value, encoder) 728 | 729 | def _make_noteq_condition(self, attribute, operation, value, encoder): 730 | if value is None: 731 | attribute = self._every(attribute) 732 | return super(every, self)._make_noteq_condition(attribute, operation, value, encoder) 733 | 734 | def _make_in_condition(self, attribute, operation, value, encoder): 735 | return super(every, self)._make_in_condition(self._every(attribute), operation, value, encoder) 736 | 737 | def _make_btwn_condition(self, attribute, operation, value, encoder): 738 | return super(every, self)._make_btwn_condition(self._every(attribute), operation, value, encoder) 739 | 740 | 741 | class item_name(where): 742 | """ 743 | Encapsulates a where clause that filters based on item names. 744 | """ 745 | def __init__(self, *equals, **query): 746 | self.connector = self.default 747 | self.children = [] 748 | for equal in equals: 749 | self.children.append(('itemName()', 'eq', equal)) 750 | for operation, value in query.iteritems(): 751 | self.children.append(('itemName()', operation, value)) 752 | 753 | 754 | class Query(object): 755 | 756 | DESCENDING = 'DESC' 757 | ASCENDING = 'ASC' 758 | 759 | def __init__(self, domain): 760 | self.domain = domain 761 | self.where = where() 762 | self.fields = [] 763 | self.limit = None 764 | self.order = None 765 | self._result_cache = None 766 | 767 | def __iter__(self): 768 | return iter(self._get_results()) 769 | 770 | def __len__(self): 771 | return len(self._get_results()) 772 | 773 | def __repr__(self): 774 | return repr(list(self)) 775 | 776 | def __getitem__(self, k): 777 | if not isinstance(k, (slice, int, long)): 778 | raise TypeError 779 | if self._result_cache: 780 | return self._result_cache[k] 781 | 782 | q = self._clone() 783 | if isinstance(k, slice) and k.stop >= 0: 784 | q.limit = k.stop + 1 785 | elif k >= 0: 786 | q.limit = k + 1 787 | return list(q)[k] 788 | 789 | def all(self): 790 | return self._clone() 791 | 792 | def limit(self, limit): 793 | q = self._clone() 794 | q.limit = limit 795 | return q 796 | 797 | def filter(self, *args, **kwargs): 798 | q = self._clone() 799 | q.where = self.where & where(*args, **kwargs) 800 | return q 801 | 802 | def values(self, *fields): 803 | q = self._clone() 804 | q.fields = fields 805 | return q 806 | 807 | def item_names(self): 808 | q = self._clone(klass=ItemNameQuery) 809 | return q 810 | 811 | def count(self): 812 | if self._result_cache: 813 | return len(self._result_cache) 814 | q = self._clone() 815 | q.fields = ['count(*)'] 816 | return int(list(q)[0]['Count']) 817 | 818 | def order_by(self, field): 819 | q = self._clone() 820 | if field[0] == '-': 821 | field = field[1:] 822 | q.order = (field, self.DESCENDING) 823 | else: 824 | q.order = (field, self.ASCENDING) 825 | return q 826 | 827 | def get(self, name): 828 | q = self._clone() 829 | q = q.filter(item_name(name)) 830 | if len(q) < 1: 831 | raise ItemDoesNotExist(name) 832 | return q[0] 833 | 834 | def to_expression(self): 835 | """ 836 | Creates the query expression for this query. Returns the expression 837 | string. 838 | """ 839 | 840 | # Used to encode attribute values in `where` instances, since they 841 | # don't know the Domain they're operating on. 842 | encoder = lambda a, v: self.domain._encode(a, v) 843 | 844 | if self.fields: 845 | output_list = self.fields 846 | else: 847 | output_list = ['*'] 848 | stmt = ['SELECT', ', '.join(output_list), 'FROM', '`%s`' % self.domain.name] 849 | if len(self.where): 850 | stmt.extend(['WHERE', self.where.to_expression(encoder)]) 851 | if self.order is not None: 852 | stmt.append('ORDER BY') 853 | stmt.extend(self.order) 854 | if self.limit is not None: 855 | stmt.append('LIMIT %s' % self.limit) 856 | return ' '.join(stmt) 857 | 858 | def _clone(self, klass=None, **kwargs): 859 | if klass is None: 860 | klass = self.__class__ 861 | q = klass(self.domain) 862 | q.where = self.where._clone() 863 | q.fields = self.fields[:] 864 | q.order = self.order 865 | q.__dict__.update(kwargs) 866 | return q 867 | 868 | def _get_results(self): 869 | if self._result_cache is None: 870 | self._result_cache = self.domain.select(self.to_expression()) 871 | return self._result_cache 872 | 873 | 874 | class ItemNameQuery(Query): 875 | def values(self, *fields): 876 | raise NotImplementedError 877 | 878 | def _get_fields(self): 879 | # always return itemName() as the sole field 880 | return ['itemName()'] 881 | 882 | def _set_fields(self, value): 883 | # ignore any attempt to set the fields attribute 884 | pass 885 | 886 | fields = property(_get_fields, _set_fields) 887 | 888 | def _get_results(self): 889 | if self._result_cache is None: 890 | self._result_cache = [item.name for item in 891 | self.domain.select(self.to_expression())] 892 | return self._result_cache 893 | 894 | 895 | class Domain(object): 896 | def __init__(self, name, simpledb): 897 | self.name = name 898 | self.simpledb = simpledb 899 | self.items = {} 900 | 901 | @property 902 | def metadata(self): 903 | return self.simpledb.get_domain_metadata(self) 904 | 905 | def filter(self, *args, **kwargs): 906 | return self._get_query().filter(*args, **kwargs) 907 | 908 | def select(self, expression): 909 | return self.simpledb.select(self, expression) 910 | 911 | def all(self): 912 | return self._get_query() 913 | 914 | def count(self): 915 | return self._get_query().count() 916 | 917 | def values(self, *args): 918 | return self._get_query().values(*args) 919 | 920 | def item_names(self): 921 | return self._get_query().item_names() 922 | 923 | def get(self, name): 924 | if name not in self.items: 925 | self.items[name] = Item.load(self.simpledb, self, name) 926 | item = self.items[name] 927 | if not item: 928 | raise ItemDoesNotExist(name) 929 | return item 930 | 931 | def _encode(self, attribute, value): 932 | # Encode an attribute, value combination using the simpledb AttributeEncoder. 933 | return self.simpledb.encoder.encode(self.name, attribute, value) 934 | 935 | def __getitem__(self, name): 936 | try: 937 | return self.get(name) 938 | except ItemDoesNotExist: 939 | return Item(self.simpledb, self, name, {}) 940 | 941 | def __setitem__(self, name, value): 942 | if not hasattr(value, '__getitem__') or isinstance(value, basestring): 943 | raise SimpleDBError('Domain items must be dict-like, not `%s`' % type(value)) 944 | del self[name] 945 | item = Item(self.simpledb, self, name, value) 946 | item.save() 947 | 948 | def __delitem__(self, name): 949 | self.simpledb.delete_attributes(self, name) 950 | if name in self.items: 951 | del self.items[name] 952 | 953 | def __unicode__(self): 954 | return self.name 955 | 956 | def __iter__(self): 957 | return iter(self.all()) 958 | 959 | def __repr__(self): 960 | return '<%s: %s>' % ( self.__class__.__name__, unicode(self)) 961 | 962 | def _get_query(self): 963 | return Query(self) 964 | 965 | 966 | class Item(DictMixin): 967 | @classmethod 968 | def load(cls, simpledb, domain, name): 969 | attrs = simpledb.get_attributes(domain, name) 970 | return cls(simpledb, domain, name, attrs) 971 | 972 | def __init__(self, simpledb, domain, name, attributes=None): 973 | self.simpledb = simpledb 974 | self.domain = domain 975 | self.name = name 976 | self.attributes = attributes or {} 977 | 978 | def __getitem__(self, name): 979 | return self.attributes[name] 980 | 981 | def __setitem__(self, name, value): 982 | self.attributes[name] = value 983 | 984 | def __delitem__(self, name): 985 | if name in self.attributes: 986 | self.simpledb.delete_attributes(self.domain, self, {name: self.attributes[name]}) 987 | del self.attributes[name] 988 | 989 | def keys(self): 990 | return self.attributes.keys() 991 | 992 | def save(self): 993 | self.simpledb.put_attributes(self.domain, self, self.attributes) 994 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | """ 2 | Basic simpledb tests. There's some setup involved in running them since you'll need 3 | an Amazon AWS account that the tests can use. To make this work you'll need a settings.py 4 | file in this directory with the appropriate authorization info. It should look like: 5 | 6 | AWS_KEY = 'XXX' 7 | AWS_SECRET = 'XXX' 8 | 9 | Several test domains will be created during the tests. They should be removed during 10 | test teardown, so they won't stick around long. If any of the domains that the tests 11 | use already exist, an error will be raised and the tests will stop. This is to prevent 12 | any accidental data corruption if there happens to be a name conflict with one of your 13 | existing domains. If this happens you'll need to manually remove the conflicting domain 14 | then re-run the tests. 15 | 16 | Note also that tests sometimes fail because of SimpleDB's eventual consistency character- 17 | istics. For example, if you insert a bunch of items and then do a count it may come up 18 | short for some period of time after the inserts. I haven't come up with a good way around 19 | this problem yet. Patches welcome. 20 | """ 21 | 22 | import unittest 23 | import simpledb 24 | import simplejson 25 | import settings 26 | from collections import defaultdict 27 | 28 | 29 | class DomainNameConflict(Exception): pass 30 | class TransactionError(Exception): pass 31 | 32 | 33 | class SimpleDBTransaction(object): 34 | """ 35 | A "transaction" simply registers modification events and allows you to 36 | call rollback or finalize to reverse or commit your changes. 37 | """ 38 | 39 | def __init__(self, sdb, data): 40 | self.sdb = sdb 41 | self.data = data 42 | self.created_domains = set() 43 | self.modified_items = defaultdict(set) 44 | self.modified_domains = set() 45 | 46 | def register_modified_item(self, domain, item): 47 | if isinstance(domain, simpledb.Domain): 48 | domain_name = domain.name 49 | else: 50 | domain_name = domain 51 | 52 | if isinstance(item, simpledb.Item): 53 | item_name = item.name 54 | else: 55 | item_name = item 56 | 57 | # We only care about domains we're tracking. 58 | if domain_name in self.data.keys(): 59 | self.modified_items[domain_name].add(item_name) 60 | 61 | def register_created_domain(self, domain): 62 | if isinstance(domain, simpledb.Domain): 63 | domain = domain.name 64 | 65 | if domain in self.data.keys(): 66 | self.modified_domains.add(domain) 67 | else: 68 | self.created_domains.add(domain) 69 | 70 | def register_deleted_domain(self, domain): 71 | if isinstance(domain, simpledb.Domain): 72 | domain = domain.name 73 | if domain in self.data.keys(): 74 | self.modified_domains.add(domain) 75 | elif domain in self.created_domains: 76 | self.created_domains.remove(domain) 77 | 78 | def rollback(self): 79 | for domain, items in self.modified_items.iteritems(): 80 | if domain in self.created_domains or domain in self.modified_domains: 81 | # Don't bother rolling back items in domains we're 82 | # going to delete or recreate from scratch. 83 | continue 84 | 85 | for item in items: 86 | if item in self.data[domain]: 87 | # If it's in data it was modified, so reverse changes. 88 | self.sdb.delete_attributes(domain, item) 89 | self.sdb.put_attributes(domain, item, self.data[domain][item]) 90 | else: 91 | # Otherwise it was created, so delete it. 92 | self.sdb.delete_attributes(domain, item) 93 | 94 | # Delete created domains. 95 | for domain in self.created_domains: 96 | del self.sdb[domain] 97 | 98 | # Delete and recreate any modified domains. 99 | for domain in self.modified_domains: 100 | self.sdb.create_domain(domain) 101 | load_data(self.sdb, domain, self.data[domain]) 102 | 103 | 104 | def finalize(self): 105 | # Don't need to do anything. 106 | pass 107 | 108 | 109 | class SimpleDB(simpledb.SimpleDB): 110 | """ 111 | Subclass of SimpleDB that registers modifications so we can roll them back after 112 | each test runs. 113 | """ 114 | transaction_stack = [] 115 | data = {} 116 | 117 | def start_transaction(self): 118 | # Transactions need their own non-transaction SimpleDB connections. 119 | sdb = simpledb.SimpleDB(self.aws_key, self.aws_secret) 120 | self.transaction_stack.append(SimpleDBTransaction(sdb, self.data)) 121 | 122 | def end_transaction(self): 123 | try: 124 | transaction = self.transaction_stack.pop() 125 | transaction.finalize() 126 | except IndexError: 127 | raise TransactionError("Tried to end transaction, but no pending transactions exist.") 128 | 129 | def rollback(self): 130 | try: 131 | transaction = self.transaction_stack.pop() 132 | transaction.rollback() 133 | except IndexError: 134 | raise TransactionError("Tried to end transaction, but no pending transactions exist.") 135 | 136 | def _register_created_domain(self, domain): 137 | try: 138 | self.transaction_stack[-1].register_created_domain(domain) 139 | except IndexError: 140 | pass 141 | 142 | def _register_modified_item(self, domain, item): 143 | try: 144 | self.transaction_stack[-1].register_modified_item(domain, item) 145 | except IndexError: 146 | pass 147 | 148 | def _register_deleted_domain(self, domain): 149 | try: 150 | self.transaction_stack[-1].register_deleted_domain(domain) 151 | except IndexError: 152 | pass 153 | 154 | def create_domain(self, name): 155 | if self.has_domain(name): 156 | raise DomainNameConflict("Domain called `%s` already exists! Abort!" % name) 157 | self._register_created_domain(name) 158 | return super(SimpleDB, self).create_domain(name) 159 | 160 | def delete_domain(self, domain): 161 | if isinstance(domain, simpledb.Domain): 162 | domain_name = domain.name 163 | else: 164 | domain_name = domain 165 | self._register_deleted_domain(domain_name) 166 | return super(SimpleDB, self).delete_domain(domain) 167 | 168 | def put_attributes(self, domain, item, attributes): 169 | self._register_modified_item(domain, item) 170 | return super(SimpleDB, self).put_attributes(domain, item, attributes) 171 | 172 | #################################### 173 | # Global SimpleDB connection object. 174 | #################################### 175 | sdb = SimpleDB(settings.AWS_KEY, settings.AWS_SECRET) 176 | 177 | 178 | class TransactionTestCase(unittest.TestCase): 179 | sdb = sdb 180 | 181 | def _pre_setup(self): 182 | self.data = simplejson.load(open('fixture.json')) 183 | # Start a transaction 184 | self.sdb.start_transaction() 185 | 186 | def _post_teardown(self): 187 | # Reverse the transaction started in _pre_setup 188 | self.sdb.rollback() 189 | 190 | def __call__(self, result=None): 191 | """ 192 | Wrapper around default __call__ method to perform common test setup. 193 | """ 194 | try: 195 | self._pre_setup() 196 | except (KeyboardInterrupt, SystemExit): 197 | raise 198 | except Exception: 199 | import sys 200 | result.addError(self, sys.exc_info()) 201 | return 202 | super(TransactionTestCase, self).__call__(result) 203 | try: 204 | self._post_teardown() 205 | except (KeyboardInterrupt, SystemExit): 206 | raise 207 | except Exception: 208 | import sys 209 | result.addError(self, sys.exc_info()) 210 | return 211 | 212 | class SimpleDBTests(TransactionTestCase): 213 | def test_count(self): 214 | self.assertEquals(self.sdb['test_users'].count(), 100) 215 | 216 | def test_create_domain(self): 217 | domain = self.sdb.create_domain('test_new_domain') 218 | self.assertTrue(isinstance(domain, simpledb.Domain)) 219 | self.assertTrue(sdb.has_domain('test_new_domain')) 220 | 221 | def test_delete_domain(self): 222 | domain = self.sdb.create_domain('test_new_domain') 223 | self.assertTrue(sdb.has_domain('test_new_domain')) 224 | del self.sdb['test_new_domain'] 225 | self.assertFalse(sdb.has_domain('test_new_domain')) 226 | 227 | def test_simpledb_dictionary(self): 228 | users = self.sdb['test_users'] 229 | self.assertTrue(isinstance(users, simpledb.Domain)) 230 | self.assertTrue('test_users' in [d.name for d in self.sdb]) 231 | 232 | def test_simpledb_domain_dictionary(self): 233 | users = self.sdb['test_users'] 234 | katie = users['katie'] 235 | self.assertTrue(isinstance(katie, simpledb.Item)) 236 | self.assertEquals(katie['age'], '24') 237 | 238 | def test_domain_setitem(self): 239 | mike = {'name': 'Mike', 'age': '25', 'location': 'San Francisco, CA'} 240 | sdb['test_users']['mike'] = mike 241 | for key, value in sdb['test_users']['mike'].iteritems(): 242 | self.assertEquals(mike[key], value) 243 | 244 | def test_delete(self): 245 | users = self.sdb['test_users'] 246 | del users['lacy']['age'] 247 | self.assertFalse('age' in users['lacy'].keys()) 248 | del users['lacy'] 249 | self.assertFalse('lacy' in users.item_names()) 250 | del sdb['test_users'] 251 | self.assertFalse('test_users' in [d.name for d in self.sdb]) 252 | 253 | def test_select(self): 254 | users = self.sdb['test_users'] 255 | self.assertEquals(len(users.filter(simpledb.where(name='Fawn') | 256 | simpledb.where(name='Katie'))), 2) 257 | k_names = ['Katie', 'Kody', 'Kenya', 'Kim'] 258 | self.assertTrue(users.filter(name__like='K%').count(), len(k_names)) 259 | for item in users.filter(name__like='K%'): 260 | self.assertTrue(item['name'] in k_names) 261 | 262 | def test_all(self): 263 | all = self.sdb['test_users'].all() 264 | self.assertEquals(len(set(i.name for i in all) - set(self.data['test_users'].keys())), 0) 265 | 266 | def test_values(self): 267 | users = self.sdb['test_users'].filter(age__lt='25').values('name', 'age') 268 | under_25 = [key for key, value in self.data['test_users'].items() if value['age'] < '25'] 269 | self.assertEquals(len(set(i.name for i in users) - set(under_25)), 0) 270 | 271 | def test_multiple_values(self): 272 | katie = self.sdb['test_users']['katie'] 273 | locations = ['San Francisco, CA', 'Centreville, VA'] 274 | katie['location'] = locations 275 | katie.save() 276 | katie = self.sdb['test_users']['katie'] 277 | self.assertTrue(locations[0] in katie['location']) 278 | self.assertTrue(locations[1] in katie['location']) 279 | self.assertTrue(len(katie['location']), 2) 280 | 281 | 282 | def load_data(sdb, domain, items): 283 | domain = sdb.create_domain(domain) 284 | items = [simpledb.Item(sdb, domain, name, attributes) for 285 | name, attributes in items.items()] 286 | 287 | # Split into lists of 25 items each (max for BatchPutAttributes). 288 | batches = [items[i:i+25] for i in xrange(0, len(items), 25)] 289 | for batch in batches: 290 | sdb.batch_put_attributes(domain, batch) 291 | 292 | 293 | if __name__ == '__main__': 294 | 295 | sdb.start_transaction() 296 | 297 | print "Loading fixtures..." 298 | 299 | domains = simplejson.load(open('fixture.json')) 300 | for domain, items in domains.iteritems(): 301 | load_data(sdb, domain, items) 302 | sdb.data = domains 303 | 304 | # Run tests. 305 | unittest.main() 306 | 307 | # Roll back transaction (delete test domains). 308 | sdb.rollback() 309 | --------------------------------------------------------------------------------