#!/usr/bin/env python # Copyright (C) 2015, 2018 IBM Corp. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ API module/class for interacting with a document in a database. """ import json import requests from requests.exceptions import HTTPError from ._2to3 import url_quote, url_quote_plus from ._common_util import response_to_json_dict from .error import CloudantDocumentException class Document(dict): """ Encapsulates a JSON document. A Document object is instantiated with a reference to a database and used to manipulate document content in a CouchDB or Cloudant database instance. In addition to basic CRUD style operations, a Document object also provides a convenient context manager. This context manager removes having to explicitly :func:`~cloudant.document.Document.fetch` the document from the remote database before commencing work on it as well as explicitly having to :func:`~cloudant.document.Document.save` the document once work is complete. For example: .. code-block:: python # Upon entry into the document context, fetches the document from the # remote database, if it exists. Upon exit from the context, saves the # document to the remote database with changes made within the context. with Document(database, 'julia006') as document: # The document is fetched from the remote database # Changes are made locally document['name'] = 'Julia' document['age'] = 6 # The document is saved to the remote database :param database: A database instance used by the Document. Can be either a ``CouchDatabase`` or ``CloudantDatabase`` instance. :param str document_id: Optional document id used to identify the document. :param str encoder: Optional JSON encoder object (extending json.JSONEncoder). :param str decoder: Optional JSON decoder object (extending json.JSONDecoder). """ def __init__(self, database, document_id=None, **kwargs): super(Document, self).__init__() self._client = database.client self._database = database self._database_host = self._client.server_url self._database_name = database.database_name if document_id: self['_id'] = document_id self.encoder = kwargs.get('encoder') or self._client.encoder self.decoder = kwargs.get('decoder') or json.JSONDecoder @property def r_session(self): """ Returns the database instance ``r_session`` used by the document. :returns: Client ``r_session`` """ return self._client.r_session @property def document_url(self): """ Constructs and returns the document URL. :returns: Document URL """ if '_id' not in self or self['_id'] is None: return None # handle design document url if self['_id'].startswith('_design/'): return '/'.join(( self._database_host, url_quote_plus(self._database_name), '_design', url_quote(self['_id'][8:], safe='') )) # handle document url return '/'.join(( self._database_host, url_quote_plus(self._database_name), url_quote(self['_id'], safe='') )) def exists(self): """ Retrieves whether the document exists in the remote database or not. :returns: True if the document exists in the remote database, otherwise False """ if '_id' not in self or self['_id'] is None: return False resp = self.r_session.head(self.document_url) if resp.status_code not in [200, 404]: resp.raise_for_status() return resp.status_code == 200 def json(self): """ Retrieves the JSON string representation of the current locally cached document object, encoded by the encoder specified in the associated client object. :returns: Encoded JSON string containing the document data """ return json.dumps(dict(self), cls=self.encoder) def create(self): """ Creates the current document in the remote database and if successful, updates the locally cached Document object with the ``_id`` and ``_rev`` returned as part of the successful response. """ # Ensure that an existing document will not be "updated" doc = dict(self) if doc.get('_rev') is not None: doc.__delitem__('_rev') headers = {'Content-Type': 'application/json'} resp = self.r_session.post( self._database.database_url, headers=headers, data=json.dumps(doc, cls=self.encoder) ) resp.raise_for_status() data = response_to_json_dict(resp) super(Document, self).__setitem__('_id', data['id']) super(Document, self).__setitem__('_rev', data['rev']) def fetch(self): """ Retrieves the content of the current document from the remote database and populates the locally cached Document object with that content. A call to fetch will overwrite any dictionary content currently in the locally cached Document object. """ if self.document_url is None: raise CloudantDocumentException(101) resp = self.r_session.get(self.document_url) resp.raise_for_status() self.clear() self.update(response_to_json_dict(resp, cls=self.decoder)) def save(self): """ Saves changes made to the locally cached Document object's data structures to the remote database. If the document does not exist remotely then it is created in the remote database. If the object does exist remotely then the document is updated remotely. In either case the locally cached Document object is also updated accordingly based on the successful response of the operation. """ headers = {} headers.setdefault('Content-Type', 'application/json') if not self.exists(): self.create() return put_resp = self.r_session.put( self.document_url, data=self.json(), headers=headers ) put_resp.raise_for_status() data = response_to_json_dict(put_resp) super(Document, self).__setitem__('_rev', data['rev']) return # Update Actions # These are handy functions to use with update_field below. @staticmethod def list_field_append(doc, field, value): """ Appends a value to a list field in a locally cached Document object. If a field does not exist it will be created first. :param Document doc: Locally cached Document object that can be a Document, DesignDocument or dict. :param str field: Name of the field list to append to. :param value: Value to append to the field list. """ if doc.get(field) is None: doc[field] = [] if not isinstance(doc[field], list): raise CloudantDocumentException(102, field) if value is not None: doc[field].append(value) @staticmethod def list_field_remove(doc, field, value): """ Removes a value from a list field in a locally cached Document object. :param Document doc: Locally cached Document object that can be a Document, DesignDocument or dict. :param str field: Name of the field list to remove from. :param value: Value to remove from the field list. """ if not isinstance(doc[field], list): raise CloudantDocumentException(102, field) doc[field].remove(value) @staticmethod def field_set(doc, field, value): """ Sets or replaces a value for a field in a locally cached Document object. To remove the field set the ``value`` to None. :param Document doc: Locally cached Document object that can be a Document, DesignDocument or dict. :param str field: Name of the field to set. :param value: Value to set the field to. """ if value is None: doc.__delitem__(field) else: doc[field] = value def _update_field(self, action, field, value, max_tries, tries=0): """ Private update_field method. Wrapped by Document.update_field. Tracks a "tries" var to help limit recursion. """ # Refresh our view of the document. self.fetch() # Update the field. action(self, field, value) # Attempt to save, retrying conflicts up to max_tries. try: self.save() except requests.HTTPError as ex: if tries < max_tries and ex.response.status_code == 409: self._update_field( action, field, value, max_tries, tries=tries+1) else: raise def update_field(self, action, field, value, max_tries=10): """ Updates a field in the remote document. If a conflict exists, the document is re-fetched from the remote database and the update is retried. This is performed up to ``max_tries`` number of times. Use this method when you want to update a single field in a document, and don't want to risk clobbering other people's changes to the document in other fields, but also don't want the caller to implement logic to deal with conflicts. For example: .. code-block:: python # Append the string 'foo' to the 'words' list of Document doc. doc.update_field( action=doc.list_field_append, field='words', value='foo' ) :param callable action: A routine that takes a Document object, a field name, and a value. The routine should attempt to update a field in the locally cached Document object with the given value, using whatever logic is appropriate. Valid actions are :func:`~cloudant.document.Document.list_field_append`, :func:`~cloudant.document.Document.list_field_remove`, :func:`~cloudant.document.Document.field_set` :param str field: Name of the field to update :param value: Value to update the field with :param int max_tries: In the case of a conflict, the number of retries to attempt """ self._update_field(action, field, value, max_tries) def delete(self): """ Removes the document from the remote database and clears the content of the locally cached Document object with the exception of the ``_id`` field. In order to successfully remove a document from the remote database, a ``_rev`` value must exist in the locally cached Document object. """ if not self.get("_rev"): raise CloudantDocumentException(103) del_resp = self.r_session.delete( self.document_url, params={"rev": self["_rev"]}, ) del_resp.raise_for_status() _id = self['_id'] self.clear() self['_id'] = _id def __enter__(self): """ Supports context like editing of document fields. Handles context entry logic. Executes a Document.fetch() upon entry. """ # We don't want to raise an exception if the document is not found # because upon __exit__ the save() call will create the document # if necessary. try: self.fetch() except HTTPError as error: if error.response.status_code != 404: raise except CloudantDocumentException as error: if error.status_code != 101: raise return self def __exit__(self, exc_type, exc_value, traceback): """ Support context like editing of document fields. Handles context exit logic. Executes a `Document.save()` upon exit if no exception occurred. """ if exc_type is None: self.save() def get_attachment( self, attachment, headers=None, write_to=None, attachment_type=None): """ Retrieves a document's attachment and optionally writes it to a file. If the content_type of the attachment is 'application/json' then the data returned will be in JSON format otherwise the response content will be returned as text or binary. :param str attachment: Attachment file name used to identify the attachment. :param dict headers: Optional, additional headers to be sent with request. :param file write_to: Optional file handler to write the attachment to. The write_to file must be opened for writing prior to including it as an argument for this method. :param str attachment_type: Optional setting to define how to handle the attachment when returning its contents from this method. Valid values are ``'text'``, ``'json'``, and ``'binary'`` If omitted then the returned content will be based on the response Content-Type. :returns: The attachment content """ # need latest rev self.fetch() attachment_url = '/'.join((self.document_url, attachment)) if headers is None: headers = {'If-Match': self['_rev']} else: headers['If-Match'] = self['_rev'] resp = self.r_session.get(attachment_url, headers=headers) resp.raise_for_status() if attachment_type is None: if resp.headers['Content-Type'].startswith('text/'): attachment_type = 'text' elif resp.headers['Content-Type'] == 'application/json': attachment_type = 'json' else: attachment_type = 'binary' if write_to is not None: if attachment_type in ('text', 'json'): write_to.write(resp.text) else: write_to.write(resp.content) if attachment_type == 'text': return resp.text if attachment_type == 'json': return response_to_json_dict(resp) return resp.content def delete_attachment(self, attachment, headers=None): """ Removes an attachment from a remote document and refreshes the locally cached document object. :param str attachment: Attachment file name used to identify the attachment. :param dict headers: Optional, additional headers to be sent with request. :returns: Attachment deletion status in JSON format """ # need latest rev self.fetch() attachment_url = '/'.join((self.document_url, attachment)) if headers is None: headers = {'If-Match': self['_rev']} else: headers['If-Match'] = self['_rev'] resp = self.r_session.delete( attachment_url, headers=headers ) resp.raise_for_status() super(Document, self).__setitem__('_rev', response_to_json_dict(resp)['rev']) # Execute logic only if attachment metadata exists locally if self.get('_attachments'): # Remove the attachment metadata for the specified attachment if self['_attachments'].get(attachment): self['_attachments'].__delitem__(attachment) # Remove empty attachment metadata from the local dictionary if not self['_attachments']: super(Document, self).__delitem__('_attachments') return response_to_json_dict(resp) def put_attachment(self, attachment, content_type, data, headers=None): """ Adds a new attachment, or updates an existing attachment, to the remote document and refreshes the locally cached Document object accordingly. :param attachment: Attachment file name used to identify the attachment. :param content_type: The http ``Content-Type`` of the attachment used as an additional header. :param data: Attachment data defining the attachment content. :param headers: Optional, additional headers to be sent with request. :returns: Attachment addition/update status in JSON format """ # need latest rev self.fetch() attachment_url = '/'.join((self.document_url, attachment)) if headers is None: headers = { 'If-Match': self['_rev'], 'Content-Type': content_type } else: headers['If-Match'] = self['_rev'] headers['Content-Type'] = content_type resp = self.r_session.put( attachment_url, data=data, headers=headers ) resp.raise_for_status() self.fetch() return response_to_json_dict(resp)