diff --git a/couchdbkit/client.py b/couchdbkit/client.py index 6c6cfed..b1141d7 100644 --- a/couchdbkit/client.py +++ b/couchdbkit/client.py @@ -26,20 +26,26 @@ >>> del server['simplecouchdb_test'] """ -from couchdbkit.logging import error_logger - -UNKOWN_INFO = {} - - from collections import deque +from copy import deepcopy from itertools import groupby +import json from mimetypes import guess_type import time +import cloudant +from cloudant.client import CouchDB +from cloudant.database import CouchDatabase +from cloudant.document import Document +from cloudant.security_document import SecurityDocument +from requests.exceptions import HTTPError from restkit.util import url_quote +import six +from six.moves.urllib.parse import urljoin, unquote +from couchdbkit.logging import error_logger from .exceptions import InvalidAttachment, NoResultFound, \ -ResourceNotFound, ResourceConflict, BulkSaveError, MultipleResultsFound + ResourceNotFound, ResourceConflict, BulkSaveError, MultipleResultsFound from . import resource from .utils import validate_dbname @@ -47,6 +53,8 @@ DEFAULT_UUID_BATCH_COUNT = 1000 +UNKOWN_INFO = {} + def _maybe_serialize(doc): if hasattr(doc, "to_json"): @@ -62,6 +70,7 @@ def _maybe_serialize(doc): return doc, False + class Server(object): """ Server object that allows you to access and manage a couchdb node. A Server object can be used like any `dict` object. @@ -104,6 +113,12 @@ def __init__(self, uri='http://127.0.0.1:5984', else: self.res = self.resource_class(uri, **client_opts) self._uuids = deque() + # admin_party is true, because the username/pass is passed in uri for now + self.cloudant_client = CouchDB('', '', url=uri, admin_party=True, connect=True) + + @property + def _request_session(self): + return self.cloudant_client.r_session def info(self): """ info of server @@ -112,17 +127,18 @@ def info(self): """ try: - resp = self.res.get() + resp = self._request_session.get(self.uri) + resp.raise_for_status() except Exception: return UNKOWN_INFO - return resp.json_body + return resp.json() def all_dbs(self): """ get list of databases in CouchDb host """ - return self.res.get('/_all_dbs').json_body + return self.cloudant_client.all_dbs() def get_db(self, dbname, **params): """ @@ -172,21 +188,21 @@ def replicate(self, source, target, **params): http://wiki.apache.org/couchdb/Replication """ - payload = { - "source": source, - "target": target, - } - payload.update(params) - resp = self.res.post('/_replicate', payload=payload) - return resp.json_body + replicator = cloudant.replicator.Replication(self.cloudant_client) + source_db = Database(self.cloudant_client, source) + target_db = Database(self.cloudant_client, target) + return replicator.create_replication(source_db, target_db, **params) def active_tasks(self): """ return active tasks """ - resp = self.res.get('/_active_tasks') - return resp.json_body + resp = self._request_session.get(urljoin(self.uri, '/_active_tasks')) + resp.raise_for_status() + return resp.json() def uuids(self, count=1): - return self.res.get('/_uuids', count=count).json_body + resp = self._request_session.get(urljoin(self.uri, '/_uuids'), params={'count': count}) + resp.raise_for_status() + return resp.json() def next_uuid(self, count=None): """ @@ -207,14 +223,12 @@ def __getitem__(self, dbname): return Database(self._db_uri(dbname), server=self) def __delitem__(self, dbname): - ret = self.res.delete('/%s/' % url_quote(dbname, - safe=":")).json_body - return ret + self.cloudant_client.delete_database(dbname) def __contains__(self, dbname): try: - self.res.head('/%s/' % url_quote(dbname, safe=":")) - except: + self.cloudant_client[dbname] + except KeyError: return False return True @@ -235,6 +249,7 @@ def _db_uri(self, dbname): dbname = url_quote(dbname, safe=":") return "/".join([self.uri, dbname]) + class Database(object): """ Object that abstract access to a CouchDB database A Database object can act as a Dict object. @@ -251,6 +266,7 @@ def __init__(self, uri, create=False, server=None, **params): """ self.uri = uri.rstrip('/') self.server_uri, self.dbname = self.uri.rsplit("/", 1) + self.cloudant_dbname = unquote(self.dbname) if server is not None: if not hasattr(server, 'next_uuid'): @@ -260,33 +276,44 @@ def __init__(self, uri, create=False, server=None, **params): else: self.server = server = Server(self.server_uri, **params) + self.cloudant_client = self.server.cloudant_client + validate_dbname(self.dbname) + self.cloudant_database = CouchDatabase(self.cloudant_client, self.cloudant_dbname) if create: - try: - self.server.res.head('/%s/' % self.dbname) - except ResourceNotFound: - self.server.res.put('/%s/' % self.dbname, **params).json_body + self.cloudant_database.create() self.res = server.res(self.dbname) + self._request_session = self.server._request_session + self.database_url = self.cloudant_database.database_url def __repr__(self): return "<%s %s>" % (self.__class__.__name__, self.dbname) + def _database_path(self, path): + return '/'.join([self.database_url, path]) + def info(self): """ Get database information @return: dict """ - return self.res.get().json_body + return self.cloudant_database.metadata() def set_security(self, secobj): """ set database securrity object """ - return self.res.put("/_security", payload=secobj).json_body + with SecurityDocument(self.cloudant_database) as sec_doc: + # context manager saves + for key in sec_doc: + del sec_doc[key] + for k, v in secobj.items(): + sec_doc[k] = v + return self.get_security() def get_security(self): """ get database secuirity object """ - return self.res.get("/_security").json_body + return self.cloudant_database.get_security_document() def compact(self, dname=None): """ compact database @@ -296,23 +323,20 @@ def compact(self, dname=None): path = "/_compact" if dname is not None: path = "%s/%s" % (path, resource.escape_docid(dname)) - res = self.res.post(path, headers={"Content-Type": - "application/json"}) - return res.json_body + path = self._database_path(path) + res = self._request_session.post(path, headers={"Content-Type": "application/json"}) + res.raise_for_status() + return res.json() def view_cleanup(self): - res = self.res.post('/_view_cleanup', headers={"Content-Type": - "application/json"}) - return res.json_body + return self.cloudant_database.view_cleanup() def flush(self): """ Remove all docs from a database except design docs.""" # save ddocs - all_ddocs = self.all_docs(startkey="_design", - endkey="_design/"+u"\u9999", - include_docs=True) + all_ddocs = self.all_docs(startkey="_design", endkey="_design/"+u"\u9999", include_docs=True) ddocs = [] for ddoc in all_ddocs: doc = ddoc['doc'] @@ -336,9 +360,7 @@ def flush(self): # we let a chance to the system to sync times = 0 while times < 10: - try: - self.server.res.head('/%s/' % self.dbname) - except ResourceNotFound: + if self.dbname in self.server: break time.sleep(0.2) times += 1 @@ -353,12 +375,8 @@ def doc_exist(self, docid): @param docid: str, document id @return: boolean, True if document exist """ - - try: - self.res.head(resource.escape_docid(docid)) - except ResourceNotFound: - return False - return True + doc = Document(self.cloudant_database, docid) + return doc.exists() def open_doc(self, docid, **params): """Get document from database @@ -382,8 +400,15 @@ def open_doc(self, docid, **params): raise TypeError("invalid schema") wrapper = schema.wrap - docid = resource.escape_docid(docid) - doc = self.res.get(docid, **params).json_body + if isinstance(docid, six.text_type): + docid = docid.encode('utf-8') + doc = Document(self.cloudant_database, docid) + try: + doc.fetch() + except HTTPError as e: + if e.response.status_code == 404: + raise ResourceNotFound + raise if wrapper is not None: if not callable(wrapper): raise TypeError("wrapper isn't a callable") @@ -480,8 +505,9 @@ def get_rev(self, docid): @return rev: str, the last revision of document. """ - response = self.res.head(resource.escape_docid(docid)) - return response['etag'].strip('"') + response = self._request_session.head(self._database_path(docid)) + response.raise_for_status() + return response.headers['ETag'].strip('"') def save_doc(self, doc, encode_attachments=True, force_update=False, **params): @@ -509,30 +535,28 @@ def save_doc(self, doc, encode_attachments=True, force_update=False, doc1['_attachments'] = resource.encode_attachments(doc['_attachments']) if '_id' in doc1: - docid = doc1['_id'] - docid1 = resource.escape_docid(doc1['_id']) + docid = doc1['_id'].encode('utf-8') + couch_doc = Document(self.cloudant_database, docid) + couch_doc.update(doc1) try: - res = self.res.put(docid1, payload=doc1, - **params).json_body - except ResourceConflict: + couch_doc.save() + except HTTPError as e: + if e.response.status_code != 409: + raise + if force_update: - doc1['_rev'] = self.get_rev(docid) - res =self.res.put(docid1, payload=doc1, - **params).json_body + couch_doc['_rev'] = self.get_rev(docid) + couch_doc.save() else: - raise + raise ResourceConflict + res = couch_doc else: - try: - doc['_id'] = self.server.next_uuid() - res = self.res.put(doc['_id'], payload=doc1, - **params).json_body - except: - res = self.res.post(payload=doc1, **params).json_body + res = self.cloudant_database.create_document(doc1) - if 'batch' in params and 'id' in res: - doc1.update({ '_id': res['id']}) + if 'batch' in params and ('id' in res or '_id' in res): + doc1.update({ '_id': res.get('_id')}) else: - doc1.update({'_id': res['id'], '_rev': res['rev']}) + doc1.update({'_id': res.get('_id'), '_rev': res.get('_rev')}) if schema: for key, value in doc.__class__.wrap(doc1).iteritems(): @@ -578,13 +602,16 @@ def is_id(doc): if nextid: doc['_id'] = nextid - payload = { "docs": docs1 } + payload = {"docs": docs1} if new_edits is not None: payload["new_edits"] = new_edits # update docs - results = self.res.post('/_bulk_docs', - payload=payload, **params).json_body + res = self._request_session.post( + self._database_path('_bulk_docs'), data=json.dumps(payload), + headers={"Content-Type": "application/json"}, **params) + res.raise_for_status() + results = res.json() errors = [] for i, res in enumerate(results): @@ -652,16 +679,24 @@ def delete_doc(self, doc, **params): result = { 'ok': False } doc1, schema = _maybe_serialize(doc) + if isinstance(doc1, dict): if not '_id' or not '_rev' in doc1: raise KeyError('_id and _rev are required to delete a doc') - docid = resource.escape_docid(doc1['_id']) - result = self.res.delete(docid, rev=doc1['_rev'], **params).json_body + couch_doc = Document(self.cloudant_database, doc1['_id']) + couch_doc['_rev'] = doc1['_rev'] elif isinstance(doc1, basestring): # we get a docid - rev = self.get_rev(doc1) - docid = resource.escape_docid(doc1) - result = self.res.delete(docid, rev=rev, **params).json_body + couch_doc = Document(self.cloudant_database, doc1) + couch_doc['_rev'] = self.get_rev(doc1) + + # manual request because cloudant library doesn't return result + res = self._request_session.delete( + couch_doc.document_url, + params={"rev": couch_doc["_rev"]}, + ) + res.raise_for_status() + result = res.json() if schema: doc._doc.update({ @@ -688,7 +723,7 @@ def copy_doc(self, doc, dest=None, headers=None): if isinstance(doc1, basestring): docid = doc1 else: - if not '_id' in doc1: + if '_id' not in doc1: raise KeyError('_id is required to copy a doc') docid = doc1['_id'] @@ -708,10 +743,11 @@ def copy_doc(self, doc, dest=None, headers=None): if destination: headers.update({"Destination": str(destination)}) - result = self.res.copy('/%s' % docid, headers=headers).json_body - return result + resp = self._request_session.request('copy', self._database_path(docid), headers=headers) + resp.raise_for_status() + return resp.json() - return { 'ok': False } + return {'ok': False} def raw_view(self, view_path, params): if 'keys' in params: @@ -767,8 +803,6 @@ def documents(self, schema=None, wrapper=None, **params): wrapper=wrapper, schema=schema, params=params) iterdocuments = documents - - def put_attachment(self, doc, content, name=None, content_type=None, content_length=None, headers=None): """ Add attachement to a document. All attachments are streamed. @@ -808,11 +842,13 @@ def put_attachment(self, doc, content, name=None, content_type=None, if not content: content = "" content_length = 0 + if name is None: if hasattr(content, "name"): name = content.name else: raise InvalidAttachment('You should provide a valid attachment name') + name = url_quote(name, safe="") if content_type is None: content_type = ';'.join(filter(None, guess_type(name))) @@ -821,7 +857,7 @@ def put_attachment(self, doc, content, name=None, content_type=None, headers['Content-Type'] = content_type # add appropriate headers - if content_length and content_length is not None: + if content_length: headers['Content-Length'] = content_length doc1, schema = _maybe_serialize(doc) @@ -855,9 +891,7 @@ def delete_attachment(self, doc, name, headers=None): doc.update(new_doc) return res['ok'] - - def fetch_attachment(self, id_or_doc, name, stream=False, - headers=None): + def fetch_attachment(self, id_or_doc, name, stream=False, headers=None): """ get attachment in a document @param id_or_doc: str or dict, doc id or document dict @@ -883,9 +917,10 @@ def fetch_attachment(self, id_or_doc, name, stream=False, def ensure_full_commit(self): """ commit all docs in memory """ - return self.res.post('_ensure_full_commit', headers={ - "Content-Type": "application/json" - }).json_body + path = self._database_path('_ensure_full_commit') + res = self._request_session.post(path, headers={"Content-Type": "application/json"}) + res.raise_for_status() + return res.json() def __len__(self): return self.info()['doc_count'] @@ -900,9 +935,8 @@ def __setitem__(self, docid, doc): doc['_id'] = docid self.save_doc(doc) - def __delitem__(self, docid): - self.delete_doc(docid) + self.delete_doc(docid) def __iter__(self): return self.documents().iterator() @@ -910,6 +944,7 @@ def __iter__(self): def __nonzero__(self): return (len(self) > 0) + class ViewResults(object): """ Object to retrieve view results. @@ -1086,6 +1121,3 @@ def __len__(self): def __nonzero__(self): return bool(len(self)) - - - diff --git a/requirements.txt b/requirements.txt index c9da5f5..29c9513 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,4 @@ restkit>=4.2.2 +jsonobject>=0.6.0 +cloudant==2.7.0 +six==1.11.0 diff --git a/setup.py b/setup.py index 5280b06..43cb205 100755 --- a/setup.py +++ b/setup.py @@ -52,6 +52,8 @@ install_requires = [ 'restkit>=4.2.2', 'jsonobject>=0.6.0', + 'cloudant==2.7.0', + 'six==1.11.0', ], provides=['couchdbkit'], obsoletes=['couchdbkit'], diff --git a/tests/client_test.py b/tests/client_test.py index dad1fd6..dac8b0d 100644 --- a/tests/client_test.py +++ b/tests/client_test.py @@ -566,8 +566,9 @@ def testCopy(self): def testSetSecurity(self): db = self.Server.create_db('couchdbkit_test') - res = db.set_security({"meta": "test"}) - self.assert_(res['ok'] == True) + sec_doc = {"meta": "test"} + res = db.set_security(sec_doc) + self.assertEquals(res, sec_doc) del self.Server['couchdbkit_test'] def testGetSecurity(self): @@ -824,4 +825,3 @@ class B(Document): if __name__ == '__main__': unittest.main() -