Commits

Anonymous committed aab0387

API breaking changes:
* changed get_docs() to return a list of docs
* changed list_docs() to return a list of dicts with doc id's and rev's
* changed save_doc() to return a dict with id and rev
* changed save save_docs() to return a list of dicts with id and rev
* changed get_attachment() to allow for the mimetype to be given as argument

Updated documentation for many methods.

Most methods on BlockingCouch now takes a raise_error parameter, indicating whether an exception should be raised on any error, or if the error description shall be included in the return value.

Added tests for all BlockingCouch methods.

Comments (0)

Files changed (3)

-^.DS_Store
-
+^\.DS_Store
+.*\.pyc
 from tornado import httpclient
 from tornado.escape import json_decode, json_encode, url_escape
 
+
+
 class BlockingCouch(object):
     '''Basic wrapper class for blocking operations on a CouchDB'''
 
         self.client = httpclient.HTTPClient()
         self.db_name = db_name
 
-    # Database operations
 
-    def create_db(self):
-        '''Creates the database'''
-        return self._http_put(''.join(['/', self.db_name, '/']), '')
+   # Database operations
 
-    def delete_db(self):
-        '''Deletes the database'''
-        return self._http_delete(''.join(['/', self.db_name, '/']))
+    def create_db(self, raise_error=True):
+        '''Creates database'''
+        return self._http_put(''.join(['/', self.db_name, '/']), raise_error=raise_error)
 
-    def list_dbs(self):
-        '''List the databases'''
-        return self._http_get('/_all_dbs')
+    def delete_db(self, raise_error=True):
+        '''Deletes database'''
+        return self._http_delete(''.join(['/', self.db_name, '/']), raise_error=raise_error)
 
-    def info_db(self):
+    def list_dbs(self, raise_error=True):
+        '''List names of databases'''
+        return self._http_get('/_all_dbs', raise_error=raise_error)
+
+    def info_db(self, raise_error=True):
         '''Get info about the database'''
-        return self._http_get(''.join(['/', self.db_name, '/']))
-        
-    def pull_db(self, source, create_target=False):
-        '''Replicate changes from a source database to current (target) db'''
+        return self._http_get(''.join(['/', self.db_name, '/']), raise_error=raise_error)
+
+    def pull_db(self, source, create_target=False, raise_error=True):
+        '''Replicate changes from a source database to current (target) database'''
         body = json_encode({'source': source, 'target': self.db_name, 'create_target': create_target})
-        return self._http_post('/_replicate', body, connect_timeout=120.0, request_timeout=120.0)
+        return self._http_post('/_replicate', body, raise_error=raise_error, connect_timeout=120.0, request_timeout=120.0)
 
     def uuids(self, count=1):
+        '''Get one or more uuids'''
         if count > 1:
-            q = ''.join(['?count=', str(count)])
+            url = ''.join(['/_uuids?count=', str(count)])
         else:
-            q = ''
-        return self._http_get(''.join(['/_uuids', q]))['uuids']
-        
+            url = '/_uuids'
+        return self._http_get(url)['uuids']
+
+
     # Document operations
     
-    def list_docs(self):
-        '''List all documents in a given database'''
-        return self._http_get(''.join(['/', self.db_name, '/_all_docs']))
+    def list_docs(self, raise_error=True):
+        '''Get dict with id and rev of all documents in the database.'''
+        resp = self._http_get(''.join(['/', self.db_name, '/_all_docs']), raise_error=raise_error)
+        return dict((row['id'], row['value']['rev']) for row in resp['rows'])
 
-    def get_doc(self, doc_id):
-        '''Open a document with the given id'''
+    def get_doc(self, doc_id, raise_error=True):
+        '''Get document with the given id.'''
         url = ''.join(['/', self.db_name, '/', url_escape(doc_id)])
-        return self._http_get(url)
+        return self._http_get(url, raise_error=raise_error)
 
-    def get_docs(self, doc_ids):
+    def get_docs(self, doc_ids, raise_error=True):
         '''Get multiple documents with the given id's'''
         url = ''.join(['/', self.db_name, '/_all_docs?include_docs=true'])
         body = json_encode({'keys': doc_ids})
-        return self._http_post(url, body)
-        
-    def save_doc(self, doc):
-        '''Save/create a document to/in a given database. On success, a copy of
-        the doc is returned with updated _id and _rev keys set.'''
+        resp = self._http_post(url, body, raise_error=raise_error)
+        return [row['doc'] if 'doc' in row else row for row in resp['rows']]
+
+    def save_doc(self, doc, raise_error=True):
+        '''Save/create a document in the database. Returns a dict with id
+           and rev of the saved doc.'''
         body = json_encode(doc)
         if '_rev' in doc:
+            # update an existing document
             url = ''.join(['/', self.db_name, '/', url_escape(doc['_id'])])
-            return self._http_put(url, body, doc=doc)
+            return self._http_put(url, body, doc=doc, raise_error=raise_error)
         else:
+            # save a new document
             url = ''.join(['/', self.db_name])
-            return self._http_post(url, body, doc=doc)
+            return self._http_post(url, body, doc=doc, raise_error=raise_error)
 
-    def save_docs(self, docs, all_or_nothing=False):
-        '''Save/create multiple documents'''
+    def save_docs(self, docs, all_or_nothing=False, raise_error=True):
+        '''Save/create multiple documents. Returns a list of dicts with id
+           and rev of the saved docs.'''
         # use bulk docs API to update the docs
         url = ''.join(['/', self.db_name, '/_bulk_docs'])
         body = json_encode({'all_or_nothing': all_or_nothing, 'docs': docs})
-        return self._http_post(url, body)
-
-    def delete_doc(self, doc):
+        return self._http_post(url, body, raise_error=raise_error)
+        
+    def delete_doc(self, doc, raise_error=True):
         '''Delete a document'''
         if '_rev' not in doc or '_id' not in doc:
             raise KeyError('No id or revision information in doc')
         url = ''.join(['/', self.db_name, '/', url_escape(doc['_id']), '?rev=', doc['_rev']])
-        return self._http_delete(url)
+        return self._http_delete(url, raise_error=raise_error)
 
-    def delete_docs(self, docs, all_or_nothing=False):
+    def delete_docs(self, docs, all_or_nothing=False, raise_error=True):
         '''Delete multiple documents'''
         if any('_rev' not in doc or '_id' not in doc for doc in docs):
             raise KeyError('No id or revision information in one or more docs')
         # mark docs as deleted
-        map(lambda doc: doc.update({'_deleted': True}), docs)
+        deleted = {'_deleted': True}
+        [doc.update(deleted) for doc in docs]
         # use bulk docs API to update the docs
         url = ''.join(['/', self.db_name, '/_bulk_docs'])
         body = json_encode({'all_or_nothing': all_or_nothing, 'docs': docs})
-        return self._http_post(url, body)
-        
-    def get_attachment(self, doc, attachment_name):
-        '''Open a document attachment'''
+        return self._http_post(url, body, raise_error=raise_error)
+
+    def get_attachment(self, doc, attachment_name, mimetype=None, raise_error=True):
+        '''Open a document attachment. The doc should at least contain an _id key.
+           If mimetype is not specified, the doc shall contain _attachments key with
+           info about the named attachment.'''
         if '_id' not in doc:
             raise ValueError('Missing key named _id in doc')
-        if '_attachments' not in doc or attachment_name not in doc['_attachments']:
-            raise ValueError('Document does not have an attachment with the '
-                             'specified name')
+        if not mimetype:
+            # get mimetype from the doc
+            if '_attachments' not in doc:
+                raise ValueError('No attachments in doc, cannot get content type of attachment')
+            elif attachment_name not in doc['_attachments']:
+                raise ValueError('Document does not have an attachment by the given name')
+            else:
+                mimetype = doc['_attachments'][attachment_name]['content_type']
         url = ''.join(['/', self.db_name, '/', url_escape(doc['_id']), '/',
                        url_escape(attachment_name)])
-        headers = {'Accept': doc['_attachments'][attachment_name]['content_type']}
-        return self._http_get(url, headers=headers)
+        headers = {'Accept': mimetype}
+        return self._http_get(url, headers=headers, raise_error=raise_error)
 
-    def save_attachment(self, doc, attachment):
-        '''Save an attachment to the specified doc. The attatchment shall be
+    def save_attachment(self, doc, attachment, raise_error=True):
+        '''Save an attachment to the specified doc. The attachment shall be
         a dict with keys: mimetype, name, data. The doc shall be a dict, at
         least having the key _id, and if doc is existing in the database,
         it shall also contain the key _rev'''
-
         if any(key not in attachment for key in ['mimetype', 'name', 'data']):
             raise KeyError('Attachment dict is missing one or more required keys')
-
         if '_rev' in doc:
             q = ''.join(['?rev=', doc['_rev']])
         else:
             q = ''
-
         url = ''.join(['/', self.db_name, '/', url_escape(doc['_id']), '/',
                        url_escape(attachment['name']), q])
         headers = {'Content-Type': attachment['mimetype']}
+        body = attachment['data']
+        return self._http_put(url, body, headers=headers, raise_error=raise_error)
 
-        return self._http_put(url, body=attachment['data'], headers=headers)
-
-    def delete_attachment(self, doc, attachment_name):
+    def delete_attachment(self, doc, attachment_name, raise_error=True):
+        '''Delete an attachment to the specified doc. The attatchment shall be
+           a dict with keys: mimetype, name, data. The doc shall be a dict, at
+           least with the keys: _id and _rev'''
         if '_rev' not in doc or '_id' not in doc:
             raise KeyError('No id or revision information in doc')
-        if '_attachments' not in doc or attachment_name not in doc['_attachments']:
-            raise ValueError('Document does not have an attachment with the '
-                             'specified name')
         url = ''.join(['/', self.db_name, '/', url_escape(doc['_id']), '/',
                        attachment_name, '?rev=', doc['_rev']])
-        return self._http_delete(url)
+        return self._http_delete(url, raise_error=raise_error)
 
-    def view(self, design_doc_name, view_name, **kwargs):
+    def view(self, design_doc_name, view_name, raise_error=True, **kwargs):
         '''Query a pre-defined view in the specified design doc.
         The following query parameters can be specified as keyword arguments.
 
             q = ''
         url = ''.join(['/', self.db_name, '/_design/', design_doc_name, '/_view/', view_name, q])
         if body:
-            return self._http_post(url, body)
+            return self._http_post(url, body, raise_error=raise_error)
         else:
-            return self._http_get(url)
-        
-    # Basic http methods
+            return self._http_get(url, raise_error=raise_error)
 
-    def _parse_response(self, resp, decode=True, doc=None):
-        if decode:
-            # decode the JSON body before returning result
-            obj = json_decode(resp.body)
+
+    # Basic http methods and utility functions
+
+    def _parse_response(self, resp, raise_error=True):
+        # the JSON body and check for errors
+        obj = json_decode(resp.body)
+        if raise_error:
             if 'error' in obj:
                 raise relax_exception(httpclient.HTTPError(resp.code, obj['reason'], resp))
-            if doc:
-                # modify doc _id and _rev keys according to the response
-                new_doc = dict(doc)
-                new_doc.update({'_id': obj['id'], '_rev': obj['rev']})
-                return new_doc
+            elif isinstance(obj, list):
+                # check if there is an error in the list of dicts, raise the first error seen
+                for item in obj:
+                    if 'error' in item:
+                        raise relax_exception(httpclient.HTTPError(resp.code, item['reason'], resp))
+            elif 'rows' in obj:
+                # check if there is an error in the result rows, raise the first error seen
+                for row in obj['rows']:
+                    if 'error' in row:
+                        raise relax_exception(httpclient.HTTPError(resp.code, row['error'], resp))
+        return obj
+
+    def _fetch(self, request, raise_error, decode=True):
+        try:
+            resp = self.client.fetch(request)
+        except httpclient.HTTPError as e:
+            if raise_error:
+                raise relax_exception(e)
             else:
-                return obj
+                return json_decode(e.response.body)
+
+        if decode:
+            return self._parse_response(resp, raise_error)
         else:
-            # return the response body
             return resp.body
 
-    def _http_get(self, uri, headers=None):
+    def _http_get(self, uri, headers=None, raise_error=True):
         if not isinstance(headers, dict):
             headers = {}
         if 'Accept' not in headers:
             headers['Accept'] = 'application/json'
             decode = True
         else:
-            # user callback shall take perform decoding, as
+            # not a JSON response, don't try to decode 
             decode = False
         r = httpclient.HTTPRequest(self.couch_url + uri, method='GET',
                                    headers=headers, use_gzip=False)
-        try:
-            resp = self.client.fetch(r)
-        except httpclient.HTTPError, e:
-            raise relax_exception(e)
+        return self._fetch(r, raise_error, decode)
 
-        return self._parse_response(resp, decode=decode)
-
-    def _http_post(self, uri, body, doc=None, **kwargs):
+    def _http_post(self, uri, body, doc=None, raise_error=True, **kwargs):
         headers = {'Accept': 'application/json',
                    'Content-Type': 'application/json'}
         r = httpclient.HTTPRequest(self.couch_url + uri, method='POST',
                                    headers=headers, body=body,
                                    use_gzip=False, **kwargs)
-        try:
-            resp = self.client.fetch(r)
-        except httpclient.HTTPError, e:
-            raise relax_exception(e)
+        return self._fetch(r, raise_error)
 
-        return self._parse_response(resp, doc=doc)
-
-    def _http_put(self, uri, body, headers=None, doc=None):
+    def _http_put(self, uri, body='', headers=None, doc=None, raise_error=True):
         if not isinstance(headers, dict):
             headers = {}
-        if 'Content-Type' not in headers and len(body) > 0:
+        if body and 'Content-Type' not in headers:
             headers['Content-Type'] = 'application/json'
         if 'Accept' not in headers:
             headers['Accept'] = 'application/json'
         r = httpclient.HTTPRequest(self.couch_url + uri, method='PUT',
                                    headers=headers, body=body, use_gzip=False)
-        try:
-            resp = self.client.fetch(r)
-        except httpclient.HTTPError, e:
-            raise relax_exception(e)
-        
-        return self._parse_response(resp, doc=doc)
+        return self._fetch(r, raise_error)
 
-    def _http_delete(self, uri):
+    def _http_delete(self, uri, raise_error=True):
         r = httpclient.HTTPRequest(self.couch_url + uri, method='DELETE',
                                    headers={'Accept': 'application/json'},
                                    use_gzip=False)
-        try:
-            resp = self.client.fetch(r)
-        except httpclient.HTTPError, e:
-            raise relax_exception(e)
+        return self._fetch(r, raise_error)
 
-        return self._parse_response(resp)
-        
+
+
 class AsyncCouch(object):
     '''Basic wrapper class for asynchronous operations on a CouchDB'''
+
     def __init__(self, db_name, host='localhost', port=5984):
         self.couch_url = 'http://{0}:{1}'.format(host, port)
         self.client = httpclient.AsyncHTTPClient()
         self.db_name = db_name
 
+
     # Database operations
 
     def create_db(self, callback=None):
         self._http_delete(''.join(['/', self.db_name, '/']), callback=callback)
 
     def list_dbs(self, callback=None):
-        '''List the databases'''
+        '''List the databases on the server'''
         self._http_get('/_all_dbs', callback=callback)
 
     def info_db(self, callback=None):
         self._http_post('/_replicate', body, callback=callback, connect_timeout=120.0, request_timeout=120.0)
 
     def uuids(self, count=1, callback=None):
-        def got_uuids(result):
+        def uuids_cb(resp):
             if callback:
-                if isinstance(result, Exception):
-                    callback(result)
+                if isinstance(resp, Exception):
+                    callback(resp)
                 else:
-                    callback(result['uuids'])
+                    callback(resp['uuids'])
         if count > 1:
-            q = ''.join(['?count=', str(count)])
+            url = ''.join(['/_uuids?count=', str(count)])
         else:
-            q = ''
-        url = ''.join(['/_uuids', q])
-        self._http_get(url, callback=got_uuids)
+            url = '/_uuids'
+        self._http_get(url, callback=uuids_cb)
+
 
     # Document operations
     
     def list_docs(self, callback=None):
-        '''List all documents in a given database'''
-        self._http_get(''.join(['/', self.db_name, '/_all_docs']),
-                      callback=callback)
+        '''Get dict with id and rev of all documents in the database'''
+        def list_docs_cb(resp):
+            if isinstance(resp, Exception):
+                callback(resp)
+            else:
+                callback(dict((row['id'], row['value']['rev']) for row in resp['rows']))
+        self._http_get(''.join(['/', self.db_name, '/_all_docs']), callback=callback)
 
     def get_doc(self, doc_id, callback=None):
         '''Open a document with the given id'''
-        self._http_get(''.join(['/', self.db_name, '/', url_escape(doc_id)]), callback=callback)
+        url = ''.join(['/', self.db_name, '/', url_escape(doc_id)])
+        self._http_get(url, callback=callback)
 
     def get_docs(self, doc_ids, callback=None):
         '''Get multiple documents with the given id's'''
         url = ''.join(['/', self.db_name, '/_all_docs?include_docs=true'])
         body = json_encode({'keys': doc_ids})
-        self._http_post(url, body, callback=callback)
+        def get_docs_cb(resp):
+            if isinstance(resp, Exception):
+                callback(resp)
+            else:
+                callback([row['doc'] if 'doc' in row else row for row in resp['rows']])
+        self._http_post(url, body, callback=get_docs_cb)
 
     def save_doc(self, doc, callback=None):
-        '''Save/create a document to/in a given database. On success, the
-        callback is passed a copy of the doc with updated _id and _rev keys set.'''
+        '''Save/create a document to/in a given database. Calls back with
+           a dict with id and rev of the saved doc.'''
         body = json_encode(doc)
         if '_id' in doc and '_rev' in doc:
             url = ''.join(['/', self.db_name, '/', url_escape(doc['_id'])])
             self._http_post(url, body, doc=doc, callback=callback)
 
     def save_docs(self, docs, callback=None, all_or_nothing=False):
-        '''Save/create multiple documents'''
+        '''Save/create multiple documents. Calls back with a list of dicts with id
+           and rev of the saved docs.'''
         # use bulk docs API to update the docs
         url = ''.join(['/', self.db_name, '/_bulk_docs'])
         body = json_encode({'all_or_nothing': all_or_nothing, 'docs': docs})
             body = json_encode({'all_or_nothing': all_or_nothing, 'docs': docs})
             self._http_post(url, body, callback=callback)
         
-    def get_attachment(self, doc, attachment_name, callback=None):
-        '''Open a document attachment'''
+    def get_attachment(self, doc, attachment_name, mimetype=None, callback=None):
+        '''Open a document attachment. The doc should at least contain an _id key.
+           If mimetype is not specified, the doc shall contain _attachments key with
+           info about the named attachment.'''
         if '_id' not in doc:
             callback(ValueError('Missing key named _id in doc'))
-        elif '_attachments' not in doc or attachment_name not in doc['_attachments']:
-            callback(ValueError('Document does not have an attachment with the '
-                                'specified name'))
-        else:
+        if not mimetype:
+            # get mimetype from the doc
+            if '_attachments' not in doc:
+                callback(ValueError('No attachments in doc, cannot get content type of attachment'))
+            elif attachment_name not in doc['_attachments']:
+                callback(ValueError('Document does not have an attachment by the given name'))
+            else:
+                mimetype = doc['_attachments'][attachment_name]['content_type']
             url = ''.join(['/', self.db_name, '/', url_escape(doc['_id']), '/',
                            url_escape(attachment_name)])
-            headers = {'Accept': doc['_attachments'][attachment_name]['content_type']}
+            headers = {'Accept': mimetype}
             self._http_get(url, headers=headers, callback=callback)
 
     def save_attachment(self, doc, attachment, callback=None):
-        '''Save an attachment to the specified doc. The attatchment shall be
+        '''Save an attachment to the specified doc. The attachment shall be
         a dict with keys: mimetype, name, data. The doc shall be a dict, at
         least having the key _id, and if doc is existing in the database,
         it shall also contain the key _rev'''
             self._http_put(url, body=attachment['data'], headers=headers, callback=callback)
 
     def delete_attachment(self, doc, attachment_name, callback=None):
+        '''Delete an attachment to the specified doc. The attatchment shall be
+           a dict with keys: mimetype, name, data. The doc shall be a dict, at
+           least with the keys: _id and _rev'''
         if '_rev' not in doc or '_id' not in doc:
             callback(KeyError('No id or revision information in doc'))
-        elif '_attachments' not in doc or attachment_name not in doc['_attachments']:
-            callback(ValueError('Document does not have an attachment with the '
-                                'specified name'))
-        else:
-            url = ''.join(['/', self.db_name, '/', url_escape(doc['_id']), '/',
-                           attachment_name, '?rev=', doc['_rev']])
-            self._http_delete(url, callback=callback)
+        url = ''.join(['/', self.db_name, '/', url_escape(doc['_id']), '/',
+                       attachment_name, '?rev=', doc['_rev']])
+        self._http_delete(url, callback=callback)
 
     def view(self, design_doc_name, view_name, callback=None, **kwargs):
         '''Query a pre-defined view in the specified design doc.
         
     # Basic http methods
 
-    def _http_callback(self, resp, callback, decode=True, doc=None):
+    def _http_callback(self, resp, callback, decode=True):
         if not callback:
             return
-        
         if resp.error and not resp.body:
             # error, with no response body, call back with exception
             callback(relax_exception(resp.error))
-            
         elif decode:
             # decode the JSON body and pass to the user callback function
             obj = json_decode(resp.body)
             if 'error' in obj:
                 callback(relax_exception(httpclient.HTTPError(resp.code, obj['reason'], resp)))
-                
-            elif doc:
-                # modify doc _id and _rev keys according to the response
-                new_doc = dict(doc)
-                new_doc.update({'_id': obj['id'], '_rev': obj['rev']})
-                callback(new_doc)
-                
             else:
                 callback(obj)
-                
         else:
             # pass the response body directly to the user callback function
             callback(resp.body)
+import json
+import re
+
+import couch
+
+def run_blocking_tests():
+    # set up tests
+    doc1 = {'msg': 'Test doc 1'}
+    doc2 = {'msg': 'Test doc 2'}
+
+    db = couch.BlockingCouch('testdb')
+    db2 = couch.BlockingCouch('testdb2')
+
+    db.delete_db(raise_error=False)
+    db2.delete_db(raise_error=False)
+
+    # create database
+    resp = db.create_db()
+    assert resp == {u'ok': True}, 'Failed to create database'
+
+    # list databases
+    resp = db.list_dbs()
+    assert db.db_name in resp, 'Database not in list of databases'
+    
+    # info_db
+    resp = db.info_db()
+    assert ('db_name' in resp) and (resp['db_name'] == db.db_name), 'No database info'
+
+    # uuids
+    resp = db.uuids()
+    assert re.match('[0-9a-f]{32}', resp[0]), 'Failed to get uuid'
+    
+    # save doc
+    resp = db.save_doc(doc1)
+    assert ('rev' in resp) and ('id' in resp), 'Failed to save doc'
+    doc1.update({'_id':resp['id'], '_rev':resp['rev']})
+    resp = db.save_doc({'_id': doc1['_id'], '_rev': 'a'}, raise_error=False)
+    assert 'error' in resp, 'No error when overwriting doc with wrong rev'
+
+    # get doc
+    resp = db.get_doc(doc1['_id'])
+    assert doc1 == resp, 'Failed to get doc'
+    resp = db.get_doc('a', raise_error=False)
+    assert 'error' in resp, 'No error on request for unexisting doc'
+
+    # save docs
+    doc1['msg2'] = 'Another message'
+    resp = db.save_docs([doc1, doc2])
+    assert all('rev' in item and 'id' in item for item in resp), 'Failed to save docs'
+    doc1['_rev'] = resp[0]['rev']
+    doc2.update({'_id':resp[1]['id'], '_rev':resp[1]['rev']})
+
+    # get docs
+    resp = db.get_docs([doc1['_id'], doc2['_id']])
+    assert [doc1, doc2] == resp, 'Failed to get docs'
+    resp = db.get_docs(['a'], raise_error=False)
+    assert 'error' in resp[0], 'No error on request for unexisting doc'
+   
+    # list docs
+    resp = db.list_docs()
+    assert {doc1['_id']:doc1['_rev'], doc2['_id']:doc2['_rev']} == resp, 'Failed listing all docs'
+
+    # pull database
+    resp = db2.pull_db('testdb', create_target=True)
+    assert 'ok' in resp, 'Replication failed'
+    assert 'testdb2' in db2.list_dbs(), 'Replication failed, new database replication not found'
+
+    # delete docs
+    resp = db2.delete_docs([doc1, doc2])
+    assert resp[0]['id']==doc1['_id'] and resp[1]['id']==doc2['_id'], 'Failed to delete docs'
+    assert not db2.list_docs(), 'Failed to delete docs, database not empty'
+
+    # delete database
+    resp = db2.delete_db()
+    assert resp == {u'ok': True}
+
+    # view (and first upload design doc)
+    design = {
+        '_id': '_design/test',
+        'views': {
+            'msg': {
+                'map': 'function(doc) { if (doc.msg) { emit(doc._id, doc.msg); } }'
+            }
+        }
+    }
+    resp = db.save_doc(design)
+    assert 'ok' in resp, 'Failed to upload design doc'
+    design['_rev'] = resp['rev']
+    resp = db.view('test', 'msg')
+    assert [doc1['_id'], doc2['_id']] == [row['key'] for row in resp['rows']], 'Failed to get view results from design doc'
+
+    # delete doc
+    resp = db.delete_doc(doc2)
+    assert resp['id'] == doc2['_id']
+
+    # save attachment
+    data = {'msg3': 'This is a test'}
+    attachment = {'mimetype': 'application/json', 'name': 'test attachment', 'data': json.dumps(data)}
+    resp = db.save_attachment(doc1, attachment)
+    assert 'ok' in resp, 'Attachment not saved'
+    doc1['_rev'] = resp['rev']
+
+    # get attachment
+    resp = db.get_attachment(doc1, 'test attachment', attachment['mimetype'])
+    assert json.loads(resp) == data, 'Attachment not loaded'
+
+    # delete attachment
+    resp = db.delete_attachment(doc1, 'test attachment')
+    assert 'ok' in resp, 'Attachment not deleted'
+    doc1['_rev'] = resp['rev']
+    
+    db.delete_db()
+
+if __name__ == '__main__':
+    run_blocking_tests()
+