├── .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 |
--------------------------------------------------------------------------------