aiocouchdb 0.8.0 documentation

aiocouchdb.v1.document

Contents

Source code for aiocouchdb.v1.document

# -*- coding: utf-8 -*-
#
# Copyright (C) 2014-2015 Alexander Shorin
# All rights reserved.
#
# This software is licensed as described in the file LICENSE, which
# you should have received as part of this distribution.
#

import asyncio
import json
import io
import uuid
from collections.abc import MutableMapping

from aiohttp.multidict import CIMultiDict
from aiocouchdb.client import Resource, HttpStreamResponse
from aiocouchdb.hdrs import (
    ACCEPT,
    CONTENT_LENGTH,
    CONTENT_TYPE,
    DESTINATION,
    ETAG,
    IF_NONE_MATCH
)
from aiocouchdb.multipart import MultipartReader, MultipartWriter

from .attachment import Attachment


__all__ = (
    'Document',
)


[docs]class Document(object): """Implementation of :ref:`CouchDB Document API <api/doc>`.""" attachment_class = Attachment def __init__(self, url_or_resource, *, docid=None, attachment_class=None): if attachment_class is not None: self.attachment_class = attachment_class if isinstance(url_or_resource, str): url_or_resource = Resource(url_or_resource) self.resource = url_or_resource self._docid = docid def __getitem__(self, attname): resource = self.resource(*attname.split('/')) return self.attachment_class(resource, name=attname) def __repr__(self): return '<{}.{}({}) object at {}>'.format( self.__module__, self.__class__.__qualname__, self.resource.url, hex(id(self))) @property
[docs] def id(self): """Returns a document id specified in class constructor.""" return self._docid
@asyncio.coroutine
[docs] def att(self, attname, *, auth=None): """Returns :class:`~aiocouchdb.v1.attachment.Attachment` instance against specified attachment. If attachment isn't accessible for auth provided credentials, this method raises :exc:`aiocouchdb.errors.HttpErrorException` with the related response status code. :param str attname: Attachment name :param auth: :class:`aiocouchdb.authn.AuthProvider` instance :rtype: :attr:`aiocouchdb.v1.document.Document.attachment_class` """ att = self[attname] resp = yield from att.resource.head(auth=auth) if resp.status != 404: yield from resp.maybe_raise_error() yield from resp.read() return att
@asyncio.coroutine
[docs] def exists(self, rev=None, *, auth=None): """Checks if `document exists`_ in the database. Assumes success on receiving response with `200 OK` status. :param str rev: Document revision :param auth: :class:`aiocouchdb.authn.AuthProvider` instance :rtype: bool .. _document exists: http://docs.couchdb.org/en/latest/api/document/common.html#head--db-docid """ params = {} if rev is not None: params['rev'] = rev resp = yield from self.resource.head(auth=auth, params=params) yield from resp.read() return resp.status == 200
@asyncio.coroutine
[docs] def modified(self, rev, *, auth=None): """Checks if `document was modified`_ in database since specified revision. :param str rev: Document revision :param auth: :class:`aiocouchdb.authn.AuthProvider` instance :rtype: bool .. _document was modified: http://docs.couchdb.org/en/latest/api/document/common.html#head--db-docid """ qrev = '"%s"' % rev resp = yield from self.resource.head(auth=auth, headers={IF_NONE_MATCH: qrev}) yield from resp.maybe_raise_error() return resp.status != 304
@asyncio.coroutine
[docs] def rev(self, *, auth=None): """Returns current document revision by using `HEAD request`_. :param auth: :class:`aiocouchdb.authn.AuthProvider` instance :rtype: str .. _HEAD request: http://docs.couchdb.org/en/latest/api/document/common.html#head--db-docid """ resp = yield from self.resource.head(auth=auth) yield from resp.maybe_raise_error() return resp.headers[ETAG].strip('"')
@asyncio.coroutine # pylint: disable=W0142, W0612, W0613
[docs] def get(self, rev=None, *, auth=None, att_encoding_info=None, attachments=None, atts_since=None, conflicts=None, deleted_conflicts=None, local_seq=None, meta=None, open_revs=None, revs=None, revs_info=None): """`Returns a document`_ object. :param str rev: Document revision :param auth: :class:`aiocouchdb.authn.AuthProvider` instance :param bool att_encoding_info: Includes encoding information in an attachment stubs :param bool attachments: Includes the Base64-encoded content of an attachments in the documents :param list atts_since: Includes attachments that was added since the specified revisions :param bool conflicts: Includes conflicts information in the documents :param bool deleted_conflicts: Includes information about deleted conflicted revisions in the document :param bool local_seq: Includes local sequence number in the document :param bool meta: Includes meta information in the document :param list open_revs: Returns the specified leaf revisions :param bool revs: Includes information about all known revisions :param bool revs_info: Includes information about all known revisions and their status :rtype: dict or list if `open_revs` specified .. _Returns a document: http://docs.couchdb.org/en/latest/api/document/common.html#get--db-docid """ params = dict((key, value) for key, value in locals().items() if (key not in {'self', 'auth'} and value is not None)) if atts_since is not None: params['atts_since'] = json.dumps(atts_since) if open_revs is not None and open_revs != 'all': params['open_revs'] = json.dumps(open_revs) resp = yield from self.resource.get(auth=auth, params=params) yield from resp.maybe_raise_error() return (yield from resp.json())
@asyncio.coroutine # pylint: disable=W0142, W0612
[docs] def get_open_revs(self, *open_revs, auth=None, att_encoding_info=None, atts_since=None, local_seq=None, revs=None): """Returns document open revisions with their attachments. Unlike :func:`get(open_revs=[...]) <aiocouchdb.v1.document.Document.get>`, this method works with :mimetype:`multipart/mixed` response returning multipart reader which is more optimized to handle large data sets with lesser memory footprint. Note, that this method always returns attachments along with leaf revisions. :param list open_revs: Leaf revisions to return. If omitted, all leaf revisions will be returned :param auth: :class:`aiocouchdb.authn.AuthProvider` instance :param bool att_encoding_info: Includes encoding information in an attachments stubs :param list atts_since: Includes attachments that was added since the specified revisions :param bool local_seq: Includes local sequence number in each document :param bool revs: Includes information about all known revisions in each document :rtype: :class:`~aiocouchdb.v1.document.OpenRevsMultipartReader` """ params = dict((key, value) for key, value in locals().items() if (key not in {'self', 'auth'} and value is not None)) if atts_since is not None: params['atts_since'] = json.dumps(atts_since) params['open_revs'] = json.dumps(open_revs) if open_revs else 'all' resp = yield from self.resource.get(auth=auth, headers={ACCEPT: 'multipart/*'}, params=params, response_class=HttpStreamResponse) yield from resp.maybe_raise_error() reader = OpenRevsMultipartReader.from_response(resp) return reader
@asyncio.coroutine # pylint: disable=W0142, W0612, W0613
[docs] def get_with_atts(self, rev=None, *, # pylint: disable=R0914 auth=None, att_encoding_info=None, atts_since=None, conflicts=None, deleted_conflicts=None, local_seq=None, meta=None, revs=None, revs_info=None): """Returns document with attachments. This method is more optimal than :func:`get(attachments=true) <aiocouchdb.v1.document.Document.get>` since it uses multipart API and doesn't requires to read all the attachments, extract then from JSON document and decode from base64. :param str rev: Document revision :param auth: :class:`aiocouchdb.authn.AuthProvider` instance :param bool att_encoding_info: Includes encoding information in an attachment stubs :param list atts_since: Includes attachments that was added since the specified revisions :param bool conflicts: Includes conflicts information in the documents :param bool deleted_conflicts: Includes information about deleted conflicted revisions in the document :param bool local_seq: Includes local sequence number in the document :param bool meta: Includes meta information in the document :param bool revs: Includes information about all known revisions :param bool revs_info: Includes information about all known revisions and their status :rtype: :class:`~aiocouchdb.v1.document.DocAttachmentsMultipartReader` """ params = dict((key, value) for key, value in locals().items() if (key not in {'self', 'auth'} and value is not None)) params['attachments'] = True if atts_since is not None: params['atts_since'] = json.dumps(atts_since) resp = yield from self.resource.get( auth=auth, headers={ACCEPT: 'multipart/*, application/json'}, params=params, response_class=HttpStreamResponse) yield from resp.maybe_raise_error() if resp.headers[CONTENT_TYPE].startswith('application/json'): # WARNING! Here be Hacks! # If document has no attachments, CouchDB returns it as JSON # so we have to fake multipart response in the name of consistency. # However, this hack may not lasts for too long. data = yield from resp.read() boundary = str(uuid.uuid4()) headers = dict(resp.headers.items()) headers[CONTENT_TYPE] = 'multipart/related;boundary=%s' % boundary resp.headers = CIMultiDict(**headers) resp.content._buffer.extend( b'--' + boundary.encode('latin1') + b'\r\n' b'Content-Type: application/json\r\n' b'\r\n' + data.rstrip() + b'\r\n' b'--' + boundary.encode('latin1') + b'--\r\n' ) return DocAttachmentsMultipartReader.from_response(resp)
@asyncio.coroutine # pylint: disable=W0142, W0612
[docs] def update(self, doc, *, atts=None, auth=None, batch=None, new_edits=None, rev=None): """`Updates a document`_ on server. :param dict doc: Document object. Should implement :class:`~collections.abc.MutableMapping` interface :param auth: :class:`aiocouchdb.authn.AuthProvider` instance :param dict atts: Attachments mapping where keys are represents attachment name and value is file-like object or bytes :param str batch: Updates in batch mode (asynchronously) This argument accepts only ``"ok"`` value. :param bool new_edits: Signs about new document edition. When ``False`` allows to create conflicts manually :param str rev: Document revision. Optional, since document ``_rev`` field is also respected :rtype: dict .. warning:: Updating document with attachments is not able to use all the advantages of multipart request due to `COUCHDB-2295`_ issue, so don't even try to update a document with several gigabytes attachments with this method. Put them one-by-one via :meth:`aiocouchdb.v1.attachment.Attachment.update` method. .. _Updates a document: http://docs.couchdb.org/en/latest/api/document/common.html#put--db-docid .. _COUCHDB-2295: https://issues.apache.org/jira/browse/COUCHDB-2295 """ params = dict((key, value) for key, value in locals().items() if (key not in {'self', 'doc', 'auth', 'atts'} and value is not None)) if not isinstance(doc, MutableMapping): raise TypeError('MutableMapping instance expected, like a dict') if '_id' in doc and doc['_id'] != self.id: raise ValueError('Attempt to store document with different ID: ' '%r ; expected: %r. May be you want to .copy() it?' % (doc['_id'], self.id)) if atts: writer = MultipartWriter('related') doc.setdefault('_attachments', {}) # A little hack to sync the order of attachments definition # between JSON and multipart body parts for name in atts: doc['_attachments'][name] = {} for name, stub in doc['_attachments'].items(): if stub: continue att = atts[name] if not isinstance(att, (bytes, io.BytesIO, io.BufferedIOBase)): raise TypeError('attachment payload should be a source of' ' binary data (bytes, BytesIO, file ' ' opened in binary mode), got %r' % att) part = writer.append(att) part.set_content_disposition('attachment', filename=name) doc['_attachments'][name] = { 'length': int(part.headers[CONTENT_LENGTH]), 'follows': True, 'content_type': part.headers[CONTENT_TYPE] } writer.append_json(doc) # CouchDB expects document at the first body part writer.parts.insert(0, writer.parts.pop()) # workaround of COUCHDB-2295, I really sorry for that body = b''.join(writer.serialize()) resp = yield from self.resource.put(auth=auth, data=body, headers=writer.headers, params=params) for info in doc['_attachments'].values(): info.pop('follows') info['stub'] = True else: resp = yield from self.resource.put(auth=auth, data=doc, params=params) yield from resp.maybe_raise_error() return (yield from resp.json())
@asyncio.coroutine
[docs] def delete(self, rev, *, auth=None, preserve_content=None): """`Deletes a document`_ from server. By default document will be deleted using `DELETE` HTTP method. On this request CouchDB removes all document fields, leaving only system ``_id`` and ``_rev`` and adding ``"_deleted": true`` one. When `preserve_content` set to ``True``, document will be marked as deleted (by adding ``"_deleted": true`` field without removing existed ones) via `PUT` request. This feature costs two requests to fetch and update the document and also such documents consumes more space by oblivious reasons. :param str rev: Document revision :param auth: :class:`aiocouchdb.authn.AuthProvider` instance :param bool preserve_content: Whenever to preserve document content on deletion :rtype: dict .. _Deletes a document: http://docs.couchdb.org/en/latest/api/document/common.html#delete--db-docid """ params = {'rev': rev} if preserve_content: doc = yield from self.get(rev=rev) doc['_deleted'] = True resp = yield from self.resource.put(auth=auth, data=doc, params=params) else: resp = yield from self.resource.delete(auth=auth, params=params) yield from resp.maybe_raise_error() return (yield from resp.json())
@asyncio.coroutine
[docs] def copy(self, newid, rev=None, *, auth=None): """`Copies a document`_ with the new ID within the same database. :param str newid: New document ID :param str rev: New document ID revision. Used for copying over existed document :param auth: :class:`aiocouchdb.authn.AuthProvider` instance :rtype: dict .. _Copies a document: http://docs.couchdb.org/en/latest/api/document/common.html#copy--db-docid """ dest = newid if rev is not None: dest += '?rev=' + rev headers = {DESTINATION: dest} resp = yield from self.resource.copy(auth=auth, headers=headers) yield from resp.maybe_raise_error() return (yield from resp.json())
[docs]class DocAttachmentsMultipartReader(MultipartReader): """Special multipart reader optimized for requesting single document with attachments. Matches output with :class:`OpenRevsMultipartReader`.""" @asyncio.coroutine
[docs] def next(self): """Emits a tuple of document object (:class:`dict`) and multipart reader of the followed attachments (if any). :rtype: tuple """ # WARNING! Here be Hacks! part = self._last_part if part is not None and part.at_eof(): self._at_eof = True reader = yield from super().next() if self._at_eof: return None, None attsreader = MultipartReader(self.headers, self.content) self._last_part = attsreader attsreader._unread = reader._unread doc = yield from reader.json() return doc, attsreader
[docs]class OpenRevsMultipartReader(MultipartReader): """Special multipart reader optimized for reading document`s open revisions with attachments.""" multipart_reader_cls = MultipartReader @asyncio.coroutine
[docs] def next(self): """Emits a tuple of document object (:class:`dict`) and multipart reader of the followed attachments (if any). :rtype: tuple """ reader = yield from super().next() if self._at_eof: return None, None if isinstance(reader, self.multipart_reader_cls): part = yield from reader.next() doc = yield from part.json() else: doc = yield from reader.json() return doc, reader

Contents