├── .gitignore ├── LICENSE ├── README.md ├── examples ├── listener-basic ├── listener-export-mongohq ├── listener-push-notifications ├── listener-simpletodo └── simpletodo ├── setup.py └── simperium ├── __init__.py ├── changes.py ├── core.py ├── optfunc.py └── test ├── __init__.py └── test_core.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013, Automattic Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | simperium-python 2 | ============== 3 | Simperium is a simple way for developers to move data as it changes, instantly and automatically. This is the Python library. You can [browse the documentation](http://simperium.com/docs/python/). 4 | 5 | You can [sign up](http://simperium.com) for a hosted version of Simperium. There are Simperium libraries for [other languages](https://simperium.com/overview/) too. 6 | 7 | This is not yet a full Simperium library for parsing diffs and changes. It's a wrapper for our [HTTP API](https://simperium.com/docs/http/) intended for scripting and basic backend development. 8 | 9 | ### License 10 | The Simperium Python library is available for free and commercial use under the MIT license. 11 | 12 | ### Getting Started 13 | To get started, first log into [https://simperium.com](https://simperium.com) and 14 | create a new application. Copy down the new app's name, api key and admin key. 15 | 16 | Next install the python client: 17 | 18 | $ sudo pip install git+https://github.com/Simperium/simperium-python.git 19 | 20 | Start python and import the lib: 21 | 22 | $ python 23 | >>> from simperium.core import Auth, Api 24 | 25 | We'll need to create a user to be able to store data: 26 | 27 | >>> auth = Auth(yourappname, yourapikey) 28 | >>> token = auth.create('joe@example.com', 'secret') 29 | >>> token 30 | '25c11ad089dd4c18b84f24bc18c58fe2' 31 | 32 | We can now store and retrieve data from simperium. Data is stored in buckets. 33 | For example, we could store a list of todo items in a todo bucket. When you 34 | store items, you need to give them a unique identifier. Uuids are usually a 35 | good choice. 36 | 37 | >>> import uuid 38 | >>> api = Api(yourappname, token) 39 | >>> todo1_id = uuid.uuid4().hex 40 | >>> api.todo.post(todo1_id, 41 | {'text': 'Read general theory of love', 'done': False}) 42 | 43 | We can retrieve this item: 44 | 45 | >>> api.todo.get(todo1_id) 46 | {'text': 'Read general theory of love', 'done': False} 47 | 48 | Store another todo: 49 | 50 | >>> api.todo.post(uuid.uuid4().hex, 51 | {'text': 'Watch battle royale', 'done': False}) 52 | 53 | You can retrieve an index of all of a buckets items: 54 | 55 | >>> api.todo.index() 56 | { 57 | 'count': 2, 58 | 'index': [ 59 | {'id': 'f6b680f8504c4e31a0e54a95401ffca0', 'v': 1}, 60 | {'id': 'c0d07bb7c46e48e693653425eca93af9', 'v': 1}], 61 | 'current': '4f8507b8faf44720dfc432b1',} 62 | 63 | Retrieve all the docuemnts in the index: 64 | 65 | >>> [api.todo.get(x['id']) for x in api.todo.index()['index']] 66 | [ 67 | {'text': 'Read general theory of love', 'done': False}, 68 | {'text': 'Watch battle royale', 'done': False}] 69 | 70 | It's also possible to get the data for each document in the index with data=True: 71 | 72 | >>> api.todo.index(data=True) 73 | { 74 | 'count': 2, 75 | 'index': [ 76 | {'id': 'f6b680f8504c4e31a0e54a95401ffca0', 'v': 1, 77 | 'd': {'text': 'Read general theory of love', 'done': False},}, 78 | {'id': 'c0d07bb7c46e48e693653425eca93af9', 'v': 1, 79 | 'd': {'text': 'Watch battle royale', 'done': False},}], 80 | 'current': '4f8507b8faf44720dfc432b1'} 81 | 82 | To update fields in an item, post the updated fields. They'll be merged 83 | with the current document: 84 | 85 | >>> api.todo.post(todo1_id, {'done': True}) 86 | >>> api.todo.get(todo1_id) 87 | {'text': 'Read general theory of love', 'done': True} 88 | 89 | Simperium items are versioned. It's possible to go back in time and retrieve 90 | previous versions of documents: 91 | 92 | >>> api.todo.get(todo1_id, version=1) 93 | {'text': 'Read general theory of love', 'done': False} 94 | 95 | Of course, you can delete items: 96 | 97 | >>> api.todo.delete(todo1_id) 98 | >>> api.todo.get(todo1_id) == None 99 | True 100 | >>> api.todo.index()['count'] 101 | 1 102 | -------------------------------------------------------------------------------- /examples/listener-basic: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from simperium import optfunc 3 | from simperium import core 4 | 5 | def main(appname, admin_key, bucket): 6 | api = core.Api(appname, admin_key) 7 | cv = None 8 | numTodos = 0 9 | try: 10 | while True: 11 | changes = api[bucket].all(cv, data=True) 12 | for change in changes: 13 | print str(change) + '\n---' 14 | cv = change['cv'] 15 | except KeyboardInterrupt: 16 | pass 17 | 18 | optfunc.main(main) 19 | -------------------------------------------------------------------------------- /examples/listener-export-mongohq: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | 4 | import pymongo 5 | 6 | from simperium import optfunc 7 | from simperium import core 8 | 9 | mongohq_url = os.environ['MONGOHQ_URL'] 10 | db = pymongo.Connection(mongohq_url)[mongohq_url.split('/')[-1]] 11 | 12 | def main(appname, admin_key, bucket): 13 | api = core.Api(appname, admin_key) 14 | cv = db['__meta__'].find_one('cv') 15 | if cv: 16 | cv = cv['cv'] 17 | 18 | try: 19 | while True: 20 | changes = api[bucket].all(cv, data=True) 21 | for change in changes: 22 | data = change['d'] 23 | print change['d'] 24 | 25 | # update mongo with the latest version of the data 26 | if data: 27 | data['_id'] = change['id'] 28 | db[bucket].save(data) 29 | else: 30 | db[bucket].remove(change['id']) 31 | 32 | # persist the cv to mongo, so changes don't need to be 33 | # re-processed after restart 34 | db['__meta__'].save({'_id': 'cv', 'cv': change['cv']}) 35 | cv = change['cv'] 36 | 37 | except KeyboardInterrupt: 38 | pass 39 | 40 | optfunc.main(main) 41 | -------------------------------------------------------------------------------- /examples/listener-push-notifications: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import binascii 4 | import APNSWrapper 5 | 6 | from simperium import optfunc 7 | from simperium import core 8 | 9 | 10 | def send_notification(deviceToken, count, text=None): 11 | wrapper = APNSWrapper.APNSNotificationWrapper('push.pem', True) 12 | message = APNSWrapper.APNSNotification() 13 | message.token(deviceToken) 14 | if count: 15 | message.badge(count) 16 | else: 17 | message.badge(-1) 18 | if text: 19 | alert = APNSWrapper.APNSAlert() 20 | alert.body(text) 21 | message.alert(alert) 22 | wrapper.append(message) 23 | wrapper.notify() 24 | 25 | 26 | def main(appname, bucket, admin_key, message_key): 27 | api = core.Admin(appname, admin_key) 28 | tokens = {} 29 | cv = api.system.get('push-notification-listener', {}).get('cv') 30 | try: 31 | while True: 32 | changes = api[bucket].all(cv, data=True) 33 | for change in changes: 34 | print str(change) + '\n---' 35 | 36 | userid = change['id'].split('/')[0] 37 | if userid not in tokens: 38 | token = api.userinfo.get('%s/info' % userid)['deviceToken'] 39 | token = token[1:-1].replace(' ', '') 40 | token = binascii.unhexlify(token) 41 | tokens[userid] = token 42 | 43 | count = api.as_user(userid)[bucket].index(limit=1)['count'] 44 | 45 | if change['o'] == 'M' and message_key in change['d']: 46 | send_notification(tokens[userid], count, change['d'][message_key]) 47 | else: 48 | send_notification(tokens[userid], count) 49 | 50 | cv = change['cv'] 51 | api.system.post('push-notification-listener', {'cv': cv}) 52 | 53 | except KeyboardInterrupt: 54 | pass 55 | 56 | optfunc.main(main) 57 | -------------------------------------------------------------------------------- /examples/listener-simpletodo: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from simperium import optfunc 4 | from simperium import core 5 | from simperium import changes 6 | 7 | 8 | class Counter(changes.ChangeProcessor): 9 | def __init__(self): 10 | self.count = 0 11 | 12 | def on_change_done(self, value): 13 | if value: self.count += 1 14 | else: self.count -= 1 15 | 16 | 17 | def main(appname, admin_key, bucket): 18 | api = core.Api(appname, admin_key) 19 | counter = Counter() 20 | cv = None 21 | try: 22 | while True: 23 | changes = api[bucket].all(cv, data=True) 24 | for change in changes: 25 | counter.process(change) 26 | print "total todos done: %s" % counter.count 27 | cv = change['cv'] 28 | except KeyboardInterrupt: 29 | pass 30 | 31 | optfunc.main(main) 32 | -------------------------------------------------------------------------------- /examples/simpletodo: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import uuid 4 | import time 5 | 6 | from simperium import optfunc 7 | from simperium import core 8 | 9 | 10 | def list(appname, token): 11 | api = core.Api(appname, token) 12 | for item in api.todo.index(data=True)['index']: 13 | if not item['d'].get('done'): 14 | print "%-40s%s" % (item['id'], item['d']['text']) 15 | 16 | 17 | def add(appname, token, text): 18 | api = core.Api(appname, token) 19 | api.todo.new({ 20 | 'text': text, 21 | 'timeStamp': time.time(), 22 | 'done': False, }) 23 | 24 | 25 | def done(appname, token, todo_id): 26 | api = core.Api(appname, token) 27 | api.todo.post(todo_id, {'done': True}) 28 | 29 | 30 | optfunc.main([list, add, done]) 31 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from distutils.core import setup 3 | 4 | install_requires = [] 5 | if sys.version_info < (2, 6): 6 | install_requires.append('simplejson') 7 | 8 | setup( 9 | name='Simperium', 10 | version='0.0.5', 11 | author='Andy Gayton', 12 | author_email='andy@simperium.com', 13 | packages=['simperium', 'simperium.test'], 14 | scripts=[], 15 | # url='http://pypi.python.org/pypi/Simperium/', 16 | # license='LICENSE.txt', 17 | description='Python client for the Simperium synchronization platform', 18 | long_description=open('README.md').read(), 19 | install_requires=install_requires,) 20 | -------------------------------------------------------------------------------- /simperium/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Simperium/simperium-python/4a64465d8c92915371b7e6dca3bd20000da10ab6/simperium/__init__.py -------------------------------------------------------------------------------- /simperium/changes.py: -------------------------------------------------------------------------------- 1 | 2 | class ChangeProcessor(object): 3 | def process(self, change): 4 | if change['o'] == 'M': 5 | if 'sv' in change: 6 | for key in change['v']: 7 | handler = getattr(self, 'on_change_%s' % key, None) 8 | if handler: 9 | handler(change['d'][key]) 10 | -------------------------------------------------------------------------------- /simperium/core.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import uuid 4 | import urllib 5 | import urllib2 6 | import httplib 7 | 8 | try: 9 | import simplejson as json 10 | except ImportError: 11 | import json 12 | 13 | 14 | class Auth(object): 15 | """ 16 | example use: 17 | 18 | >>> from simperium.core import Auth 19 | >>> auth = Auth('myapp', 'cbbae31841ac4d44a93cd82081a5b74f') 20 | >>> Auth.create('john@company.com', 'secret123') 21 | 'db3d2a64abf711e0b63012313d001a3b' 22 | """ 23 | def __init__(self, appname, api_key, host=None, scheme='https'): 24 | if not host: 25 | host = os.environ.get('SIMPERIUM_AUTHHOST', 'auth.simperium.com') 26 | self.appname = appname 27 | self.api_key = api_key 28 | self.host = host 29 | self.scheme = scheme 30 | 31 | def _request(self, url, data=None, headers=None, method=None): 32 | url = '%s://%s/1/%s' % (self.scheme, self.host, url) 33 | if not headers: 34 | headers = {} 35 | if data: 36 | data = urllib.urlencode(data) 37 | request = urllib2.Request(url, data, headers=headers) 38 | if method: 39 | request.get_method = lambda: method 40 | response = urllib2.urlopen(request) 41 | return response 42 | 43 | def create(self, username, password): 44 | data = { 45 | 'client_id': self.api_key, 46 | 'username': username, 47 | 'password': password, } 48 | try: 49 | response = self._request(self.appname+'/create/', data) 50 | return json.loads(response.read())['access_token'] 51 | except urllib2.HTTPError: 52 | return None 53 | 54 | def authorize(self, username, password): 55 | data = { 56 | 'client_id': self.api_key, 57 | 'username': username, 58 | 'password': password, } 59 | response = self._request(self.appname+'/authorize/', data) 60 | return json.loads(response.read())['access_token'] 61 | 62 | 63 | class Bucket(object): 64 | """ 65 | example use: 66 | 67 | >>> from simperium.core import Bucket 68 | >>> bucket = Bucket('myapp', 'db3d2a64abf711e0b63012313d001a3b', 'mybucket') 69 | >>> bucket.set('item2', {'age': 23}) 70 | True 71 | >>> bucket.set('item2', {'age': 25}) 72 | True 73 | >>> bucket.get('item2') 74 | {'age': 25} 75 | >>> bucket.get('item2', version=1) 76 | {'age': 23} 77 | """ 78 | 79 | BATCH_DEFAULT_SIZE = 100 80 | 81 | def __init__(self, appname, auth_token, bucket, 82 | userid=None, 83 | host=None, 84 | scheme='https', 85 | clientid=None): 86 | 87 | if not host: 88 | host = os.environ.get('SIMPERIUM_APIHOST', 'api.simperium.com') 89 | 90 | self.userid = userid 91 | self.host = host 92 | self.scheme = scheme 93 | self.appname = appname 94 | self.bucket = bucket 95 | self.auth_token = auth_token 96 | if clientid: 97 | self.clientid = clientid 98 | else: 99 | self.clientid = 'py-%s' % uuid.uuid4().hex 100 | 101 | def _auth_header(self): 102 | headers = {'X-Simperium-Token': '%s' % self.auth_token} 103 | if self.userid: 104 | headers['X-Simperium-User'] = self.userid 105 | return headers 106 | 107 | def _gen_ccid(self): 108 | return uuid.uuid4().hex 109 | 110 | def _request(self, url, data=None, headers=None, method=None, timeout=None): 111 | url = '%s://%s/1/%s' % (self.scheme, self.host, url) 112 | if not headers: 113 | headers = {} 114 | request = urllib2.Request(url, data, headers=headers) 115 | if method: 116 | request.get_method = lambda: method 117 | if sys.version_info < (2, 6): 118 | response = urllib2.urlopen(request) 119 | else: 120 | response = urllib2.urlopen(request, timeout=timeout) 121 | return response 122 | 123 | def index(self, data=False, mark=None, limit=None, since=None): 124 | """ 125 | retrieve a page of the latest versions of a buckets documents 126 | ordered by most the most recently modified. 127 | 128 | @mark: mark the documents returned to be modified after the 129 | given cv 130 | @limit: limit page size to this number. max 1000, default 100. 131 | @since: limit page to documents changed since the given cv. 132 | @data: include the current data state of each document in the 133 | result. by default data is not included. 134 | 135 | returns: { 136 | 'current': head cv of the most recently modified document, 137 | 'mark': cv to use to pull the next page of documents. only 138 | included in the repsonse if there are remaining pages 139 | to fetch. 140 | 'count': the total count of documents available, 141 | 142 | 'index': [{ 143 | 'id': id of the document, 144 | 'v: current version of the document, 145 | 'd': optionally current data of the document, if 146 | data is requested 147 | }, {....}], 148 | } 149 | """ 150 | url = '%s/%s/index' % (self.appname, self.bucket) 151 | 152 | args = {} 153 | if data: 154 | args['data'] = '1' 155 | if mark: 156 | args['mark'] = str(mark) 157 | if limit: 158 | args['limit'] = str(limit) 159 | if since: 160 | args['since'] = str(since) 161 | args = urllib.urlencode(args) 162 | if len(args): 163 | url += '?'+args 164 | 165 | response = self._request(url, headers=self._auth_header()) 166 | return json.loads(response.read()) 167 | 168 | def get(self, item, default=None, version=None): 169 | """retrieves either the latest version of item from this bucket, or the 170 | specific version requested""" 171 | url = '%s/%s/i/%s' % (self.appname, self.bucket, item) 172 | if version: 173 | url += '/v/%s' % version 174 | try: 175 | response = self._request(url, headers=self._auth_header()) 176 | except urllib2.HTTPError, e: 177 | if getattr(e, 'code') == 404: 178 | return default 179 | raise 180 | 181 | return json.loads(response.read()) 182 | 183 | def post(self, item, data, version=None, ccid=None, include_response=False, replace=False): 184 | """posts the supplied data to item 185 | 186 | returns a unique change id on success, or None, if the post was not 187 | successful 188 | """ 189 | if not ccid: 190 | ccid = self._gen_ccid() 191 | url = '%s/%s/i/%s' % (self.appname, self.bucket, item) 192 | if version: 193 | url += '/v/%s' % version 194 | url += '?clientid=%s&ccid=%s' % (self.clientid, ccid) 195 | if include_response: 196 | url += '&response=1' 197 | if replace: 198 | url += '&replace=1' 199 | data = json.dumps(data) 200 | try: 201 | response = self._request(url, data, headers=self._auth_header()) 202 | except urllib2.HTTPError: 203 | return None 204 | if include_response: 205 | return item, json.loads(response.read()) 206 | else: 207 | return item 208 | 209 | def bulk_post(self, bulk_data, wait=True): 210 | """posts multiple items at once, bulk_data should be a map like: 211 | 212 | { "item1" : { data1 }, 213 | "item2" : { data2 }, 214 | ... 215 | } 216 | 217 | returns an array of change responses (check for error codes) 218 | """ 219 | changes_list = [] 220 | for itemid, data in bulk_data.items(): 221 | change = { 222 | "id" : itemid, 223 | "o" : "M", 224 | "v" : {}, 225 | "ccid" : self._gen_ccid(), 226 | } 227 | # manually construct jsondiff, equivalent to jsondiff.diff( {}, data ) 228 | for k, v in data.items(): 229 | change['v'][k] = {'o': '+', 'v': v} 230 | 231 | changes_list.append( change ) 232 | 233 | url = '%s/%s/changes?clientid=%s' % (self.appname, self.bucket, self.clientid) 234 | 235 | if wait: 236 | url += '&wait=1' 237 | 238 | response = self._request(url, json.dumps(changes_list), headers=self._auth_header()) 239 | 240 | if not wait: 241 | # changes successfully submitted - check /changes 242 | return True 243 | 244 | # check each change response for 'error' 245 | return json.loads(response.read()) 246 | 247 | 248 | def new(self, data, ccid=None): 249 | return self.post(uuid.uuid4().hex, data, ccid=ccid) 250 | 251 | def set(self, item, data, **kw): 252 | return self.post(item, data, **kw) 253 | 254 | def delete(self, item, version=None): 255 | """deletes the item from bucket""" 256 | ccid = self._gen_ccid() 257 | url = '%s/%s/i/%s' % (self.appname, self.bucket, item) 258 | if version: 259 | url += '/v/%s' % version 260 | url += '?clientid=%s&ccid=%s' % (self.clientid, ccid) 261 | response = self._request(url, headers=self._auth_header(), method='DELETE') 262 | if not response.read().strip(): 263 | return ccid 264 | 265 | def changes(self, cv=None, timeout=None): 266 | """retrieves updates for this bucket for this user 267 | 268 | @cv: if supplied only updates that occurred after this 269 | change version are retrieved. 270 | 271 | @timeout: the call will wait for updates if not are immediately 272 | available. by default it will wait indefinitely. if a timeout 273 | is supplied an empty list will be return if no updates are made 274 | before the timeout is reached. 275 | """ 276 | url = '%s/%s/changes?clientid=%s' % ( 277 | self.appname, self.bucket, self.clientid) 278 | if cv is not None: 279 | url += '&cv=%s' % cv 280 | headers = self._auth_header() 281 | try: 282 | response = self._request(url, headers=headers, timeout=timeout) 283 | except httplib.BadStatusLine: 284 | return [] 285 | except Exception, e: 286 | if any(msg in str(e) for msg in ['timed out', 'Connection refused', 'Connection reset']) or \ 287 | getattr(e, 'code', None) in [502, 504]: 288 | return [] 289 | raise 290 | return json.loads(response.read()) 291 | 292 | def all(self, cv=None, data=False, username=False, most_recent=False, timeout=None, skip_clientids=[], batch=None): 293 | """retrieves *all* updates for this bucket, regardless of the user 294 | which made the update. 295 | 296 | @cv: if supplied only updates that occurred after this 297 | change version are retrieved. 298 | 299 | @data: if True, also include the lastest version of the data for 300 | changed entity 301 | 302 | @username: if True, also include the username that created the 303 | change 304 | 305 | @most_recent: if True, then only the most recent change for each 306 | document in the current page will be returned. e.g. if a 307 | document has been recently changed 3 times, only the latest of 308 | those 3 changes will be returned. 309 | 310 | @timeout: the call will wait for updates if not are immediately 311 | available. by default it will wait indefinitely. if a timeout 312 | is supplied an empty list will be return if no updates are made 313 | before the timeout is reached. 314 | """ 315 | url = '%s/%s/all?clientid=%s' % ( 316 | self.appname, self.bucket, self.clientid) 317 | if cv is not None: 318 | url += '&cv=%s' % cv 319 | if username: 320 | url += '&username=1' 321 | if data: 322 | url += '&data=1' 323 | if most_recent: 324 | url += '&most_recent=1' 325 | for clientid in skip_clientids: 326 | url += '&skip_clientid=%s' % urllib.quote_plus(clientid) 327 | try: 328 | url += '&batch=%d' % int(batch) 329 | except: 330 | url += '&batch=%d' % self.BATCH_DEFAULT_SIZE 331 | headers = self._auth_header() 332 | try: 333 | response = self._request(url, headers=headers, timeout=timeout) 334 | except httplib.BadStatusLine: 335 | return [] 336 | except Exception, e: 337 | if any(msg in str(e) for msg in ['timed out', 'Connection refused', 'Connection reset']) or \ 338 | getattr(e, 'code', None) in [502, 504]: 339 | return [] 340 | raise 341 | return json.loads(response.read()) 342 | 343 | 344 | class SPUser(object): 345 | """ 346 | example use: 347 | 348 | >>> from simperium.core import SPUser 349 | >>> user = SPUser('myapp', 'db3d2a64abf711e0b63012313d001a3b') 350 | >>> bucket.post({'age': 23}) 351 | True 352 | >>> bucket.get() 353 | {'age': 23} 354 | """ 355 | def __init__(self, appname, auth_token, 356 | host=None, 357 | scheme='https', 358 | clientid=None): 359 | 360 | self.bucket = Bucket(appname, auth_token, 'spuser', 361 | host=host, 362 | scheme=scheme, 363 | clientid=clientid) 364 | 365 | def get(self): 366 | return self.bucket.get('info') 367 | 368 | def post(self, data): 369 | self.bucket.post('info', data) 370 | 371 | 372 | class Api(object): 373 | def __init__(self, appname, auth_token, **kw): 374 | self.appname = appname 375 | self.token = auth_token 376 | self._kw = kw 377 | 378 | def __getattr__(self, name): 379 | return Api.__getitem__(self, name) 380 | 381 | def __getitem__(self, name): 382 | if name.lower() == 'spuser': 383 | return SPUser(self.appname, self.token, **self._kw) 384 | return Bucket(self.appname, self.token, name, **self._kw) 385 | 386 | 387 | class Admin(Api): 388 | def __init__(self, appname, admin_token, **kw): 389 | self.appname = appname 390 | self.token = admin_token 391 | self._kw = kw 392 | 393 | def as_user(self, userid): 394 | return Api(self.appname, self.token, userid=userid, **self._kw) 395 | -------------------------------------------------------------------------------- /simperium/optfunc.py: -------------------------------------------------------------------------------- 1 | """ 2 | example: 3 | 4 | import optfunc 5 | @optfunc.arghelp('rest_','input files') 6 | def main(rest_=['-'],keyfields=1,sep='\t',usage_='-h usage'): 7 | "-h usage" # optional: usage_ arg instead 8 | pass 9 | 10 | """ 11 | 12 | from optparse import OptionParser, make_option 13 | import sys, inspect, re 14 | 15 | doc_name='usage_' 16 | rest_name='rest_' # remaining positional arguments into this function arg as list 17 | single_char_prefix_re = re.compile('^[a-zA-Z0-9]_') 18 | 19 | # Set this to any message you want to be printed 20 | # before the standard help 21 | # This could include application name, description 22 | header = 'usage: %s COMMAND [ARGS]\n\nThe available commands are:' % sys.argv[0] 23 | 24 | # non-standard separator to use 25 | subcommand_sep = '\n' 26 | 27 | class ErrorCollectingOptionParser(OptionParser): 28 | def __init__(self, *args, **kwargs): 29 | self._errors = [] 30 | self._custom_names = {} 31 | # can't use super() because OptionParser is an old style class 32 | OptionParser.__init__(self, *args, **kwargs) 33 | 34 | def parse_args(self, argv): 35 | options, args = OptionParser.parse_args(self, argv) 36 | for k,v in options.__dict__.iteritems(): 37 | if k in self._custom_names: 38 | options.__dict__[self._custom_names[k]] = v 39 | del options.__dict__[k] 40 | return options, args 41 | 42 | def error(self, msg): 43 | self._errors.append(msg) 44 | 45 | optypes=[int,long,float,complex] # not type='choice' choices='a|b' 46 | def optype(t): 47 | if t is bool: 48 | return None 49 | if t in optypes: 50 | return t 51 | return "string" 52 | 53 | def func_to_optionparser(func): 54 | args, varargs, varkw, defaultvals = inspect.getargspec(func) 55 | defaultvals = defaultvals or () 56 | options = dict(zip(args[-len(defaultvals):], defaultvals)) 57 | helpdict = getattr(func, 'optfunc_arghelp', {}) 58 | def defaulthelp(examples): 59 | return ' (default: %s)'%examples 60 | posargshelp='\n\t(positional args):\t%s%s'% ( 61 | helpdict.get(rest_name,''), 62 | defaulthelp(options[rest_name])) if rest_name in options else '' 63 | options.pop(rest_name, None) 64 | ds=func.__doc__ 65 | if ds is None: 66 | ds='' 67 | if doc_name in options: 68 | ds+=str(options[doc_name]) 69 | options.pop(doc_name) 70 | argstart = 0 71 | if func.__name__ == '__init__': 72 | argstart = 1 73 | if defaultvals: 74 | required_args = args[argstart:-len(defaultvals)] 75 | else: 76 | required_args = args[argstart:] 77 | 78 | args = filter( lambda x: x != rest_name, args ) 79 | # Build the OptionParser: 80 | 81 | opt = ErrorCollectingOptionParser(usage = ds+posargshelp) 82 | 83 | # Add the options, automatically detecting their -short and --long names 84 | shortnames = set(['h']) 85 | for name,_ in options.items(): 86 | if single_char_prefix_re.match(name): 87 | shortnames.add(name[0]) 88 | for argname, example in options.items(): 89 | # They either explicitly set the short with x_blah... 90 | name = argname 91 | if single_char_prefix_re.match(name): 92 | short = name[0] 93 | name = name[2:] 94 | opt._custom_names[name] = argname 95 | # Or we pick the first letter from the name not already in use: 96 | else: 97 | short=None 98 | for s in name: 99 | if s not in shortnames: 100 | short=s 101 | break 102 | names=[] 103 | if short is not None: 104 | shortnames.add(short) 105 | short_name = '-%s' % short 106 | names.append(short_name) 107 | longn=name.replace('_', '-') 108 | long_name = '--%s' % longn 109 | names.append(long_name) 110 | if isinstance(example, bool): 111 | no_name='--no%s'%longn 112 | opt.add_option(make_option( 113 | no_name, action='store_false', dest=name,help = helpdict.get(argname, 'unset %s'%long_name) 114 | )) 115 | action = 'store_true' 116 | else: 117 | action = 'store' 118 | examples=str(example) 119 | if isinstance(example, int): 120 | if example==sys.maxint: examples="INFINITY" 121 | if example==(-sys.maxint-1): examples="-INFINITY" 122 | help_post=defaulthelp(examples) 123 | kwargs=dict(action=action, dest=name, default=example, 124 | help = helpdict.get(argname, '')+help_post, 125 | type=optype(type(example))) 126 | opt.add_option(make_option(*names,**kwargs)) 127 | 128 | return opt, required_args 129 | 130 | def resolve_args(func, argv, func_name=None): 131 | parser, required_args = func_to_optionparser(func) 132 | options, args = parser.parse_args(argv) 133 | 134 | # Special case for stdin/stdout/stderr 135 | for pipe in ('stdin', 'stdout', 'stderr'): 136 | if pipe in required_args: 137 | required_args.remove(pipe) 138 | setattr(options, 'optfunc_use_%s' % pipe, True) 139 | 140 | # Do we have correct number af required args? 141 | if len(required_args) > len(args): 142 | if not hasattr(func, 'optfunc_notstrict'): 143 | extra = len(parser._get_all_options()) > 1 and ' [options]' or '' 144 | command = sys.argv[0] 145 | if func_name: 146 | command += ' ' + func_name 147 | parser._errors.append("usage: %s %s%s" % ( 148 | command, ' '.join('<%s>' % x for x in required_args), extra)) 149 | 150 | 151 | # Ensure there are enough arguments even if some are missing 152 | args += [None] * (len(required_args) - len(args)) 153 | for i, name in enumerate(required_args): 154 | setattr(options, name, args[i]) 155 | args[i] = None 156 | 157 | fargs, varargs, varkw, defaults = inspect.getargspec(func) 158 | if rest_name in fargs: 159 | args = filter( lambda x: x is not None, args ) 160 | setattr(options, rest_name, tuple(args)) 161 | 162 | return options.__dict__, parser._errors 163 | 164 | def run(func, argv=None, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, 165 | hide_exception_detail=False): 166 | argv = argv or sys.argv[1:] 167 | 168 | # Handle multiple functions 169 | if isinstance(func, (tuple, list)): 170 | funcs = dict([(fn.__name__.replace('_', '-'), fn) for fn in func]) 171 | try: 172 | func_name = argv.pop(0) 173 | except IndexError: 174 | func_name = None 175 | if func_name not in funcs: 176 | def format( fn ): 177 | blurb = "" 178 | if fn.__doc__: 179 | blurb = fn.__doc__.strip().split('\n')[0] 180 | return " %-10s%s" % (fn.__name__.replace('_', '-'), blurb) 181 | 182 | names = [format(fn) for fn in func] 183 | 184 | s = subcommand_sep.join(names) 185 | stderr.write("%s\n%s\n" % (header, s) ) 186 | return 187 | func = funcs[func_name] 188 | 189 | else: 190 | func_name = None 191 | 192 | if inspect.isfunction(func): 193 | resolved, errors = resolve_args(func, argv, func_name=func_name) 194 | elif inspect.isclass(func): 195 | if hasattr(func, '__init__'): 196 | resolved, errors = resolve_args( 197 | func.__init__, argv, func_name=func_name) 198 | else: 199 | resolved, errors = {}, [] 200 | else: 201 | raise TypeError('arg is not a Python function or class') 202 | 203 | # Special case for stdin/stdout/stderr 204 | for pipe in ('stdin', 'stdout', 'stderr'): 205 | if resolved.pop('optfunc_use_%s' % pipe, False): 206 | resolved[pipe] = locals()[pipe] 207 | 208 | if not errors: 209 | try: 210 | return func(**resolved) 211 | except Exception as e: 212 | stderr.write(str(e) + '\n') 213 | if not hide_exception_detail: 214 | raise 215 | else: 216 | stderr.write("%s\n" % '\n'.join(errors)) 217 | 218 | def caller_module(i): 219 | if (i>=0): 220 | i+=1 221 | stk=inspect.stack()[i] 222 | return inspect.getmodule(stk[0]) 223 | 224 | def main(*args, **kwargs): 225 | mod=caller_module(1) 226 | if mod is None or mod.__name__ == '' or mod.__name__ == '__main__': 227 | run(*args, **kwargs) 228 | return args[0] # So it won't break anything if used as a decorator 229 | 230 | # Decorators 231 | def notstrict(fn): 232 | fn.optfunc_notstrict = True 233 | return fn 234 | 235 | def arghelp(name, help): 236 | def inner(fn): 237 | d = getattr(fn, 'optfunc_arghelp', {}) 238 | d[name] = help 239 | setattr(fn, 'optfunc_arghelp', d) 240 | return fn 241 | return inner 242 | -------------------------------------------------------------------------------- /simperium/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Simperium/simperium-python/4a64465d8c92915371b7e6dca3bd20000da10ab6/simperium/test/__init__.py -------------------------------------------------------------------------------- /simperium/test/test_core.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import unittest 4 | import uuid 5 | 6 | from simperium import core 7 | 8 | api_key = os.environ.get('SIMPERIUM_CLIENT_TEST_APIKEY') 9 | appname = os.environ.get('SIMPERIUM_CLIENT_TEST_APPNAME') 10 | 11 | if not appname: 12 | print 13 | print 14 | print "\tset SIMPERIUM_CLIENT_TEST_APPNAME and SIMPERIUM_CLIENT_TEST_APIKEY" 15 | print 16 | sys.exit() 17 | 18 | # cache user create to cut down on the number of users created by the test suite 19 | _username = None 20 | _password = None 21 | _auth_token = None 22 | def get_auth_token(): 23 | global _username 24 | global _password 25 | global _auth_token 26 | if not _auth_token: 27 | auth = core.Auth(appname, api_key) 28 | _username = uuid.uuid4().hex + '@foo.com' 29 | _password = uuid.uuid4().hex 30 | _auth_token = auth.create(_username, _password) 31 | return _auth_token 32 | 33 | 34 | class AuthTest(unittest.TestCase): 35 | def test_authorize(self): 36 | get_auth_token() 37 | auth = core.Auth(appname, api_key) 38 | auth.authorize(_username, _password) 39 | 40 | 41 | 42 | class BucketTest(unittest.TestCase): 43 | def test_get(self): 44 | bucket = core.Bucket(appname, get_auth_token(), uuid.uuid4().hex) 45 | bucket.post('item1', {'x': 1}) 46 | self.assertEqual(bucket.get('item1'), {'x': 1}) 47 | 48 | def test_index(self): 49 | bucket = core.Bucket(appname, get_auth_token(), uuid.uuid4().hex) 50 | for i in range(3): 51 | bucket.post('item%s' % i, {'x': i}) 52 | 53 | got = bucket.index(limit=2) 54 | want = { 55 | 'current': got['current'], 56 | 'mark': got['mark'], 57 | 'index': [ 58 | {'id': 'item2', 'v': 1}, 59 | {'id': 'item1', 'v': 1}], } 60 | self.assertEqual(want, got) 61 | 62 | got2 = bucket.index(limit=2, mark=got['mark']) 63 | want2 = { 64 | 'current': got['current'], 65 | 'index': [ 66 | {'id': 'item0', 'v': 1}], } 67 | self.assertEqual(want, got) 68 | 69 | def test_post(self): 70 | bucket = core.Bucket(appname, get_auth_token(), uuid.uuid4().hex) 71 | bucket.post('item1', {'a':1}) 72 | self.assertEqual(bucket.get('item1'), {'a':1}) 73 | bucket.post('item1', {'b':2}) 74 | self.assertEqual(bucket.get('item1'), {'a':1, 'b':2}) 75 | bucket.post('item1', {'c':3}, replace=True) 76 | self.assertEqual(bucket.get('item1'), {'c':3}) 77 | 78 | 79 | class SPUserTest(unittest.TestCase): 80 | def test_get(self): 81 | user = core.SPUser(appname, get_auth_token()) 82 | user.post({'x': 1}) 83 | self.assertEqual(user.get(), {'x': 1}) 84 | 85 | 86 | class ApiTest(unittest.TestCase): 87 | def test_getitem(self): 88 | api = core.Api(appname, get_auth_token()) 89 | self.assertTrue(isinstance(api['bucket'], core.Bucket)) 90 | 91 | def test_getattr(self): 92 | api = core.Api(appname, get_auth_token()) 93 | self.assertTrue(isinstance(api.bucket, core.Bucket)) 94 | 95 | def test_user(self): 96 | api = core.Api(appname, get_auth_token()) 97 | self.assertTrue(isinstance(api.SPUser, core.SPUser)) 98 | --------------------------------------------------------------------------------