1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2007-2009 Christopher Lenz
4# All rights reserved.
5#
6# This software is licensed as described in the file COPYING, which
7# you should have received as part of this distribution.
8
9"""Python client API for CouchDB.
10
11>>> server = Server()
12>>> db = server.create('python-tests')
13>>> doc_id, doc_rev = db.save({'type': 'Person', 'name': 'John Doe'})
14>>> doc = db[doc_id]
15>>> doc['type']
16'Person'
17>>> doc['name']
18'John Doe'
19>>> del db[doc.id]
20>>> doc.id in db
21False
22
23>>> del server['python-tests']
24"""
25
26import mimetypes
27import os
28from types import FunctionType
29from inspect import getsource
30from textwrap import dedent
31import re
32import warnings
33
34from couchdb import http, json
35
36__all__ = ['Server', 'Database', 'Document', 'ViewResults', 'Row']
37__docformat__ = 'restructuredtext en'
38
39
40DEFAULT_BASE_URL = os.environ.get('COUCHDB_URL', 'http://localhost:5984/')
41
42
43class Server(object):
44    """Representation of a CouchDB server.
45
46    >>> server = Server()
47
48    This class behaves like a dictionary of databases. For example, to get a
49    list of database names on the server, you can simply iterate over the
50    server object.
51
52    New databases can be created using the `create` method:
53
54    >>> db = server.create('python-tests')
55    >>> db
56    <Database 'python-tests'>
57
58    You can access existing databases using item access, specifying the database
59    name as the key:
60
61    >>> db = server['python-tests']
62    >>> db.name
63    'python-tests'
64
65    Databases can be deleted using a ``del`` statement:
66
67    >>> del server['python-tests']
68    """
69
70    def __init__(self, url=DEFAULT_BASE_URL, full_commit=True, session=None):
71        """Initialize the server object.
72
73        :param url: the URI of the server (for example
74                    ``http://localhost:5984/``)
75        :param full_commit: turn on the X-Couch-Full-Commit header
76        :param session: an http.Session instance or None for a default session
77        """
78        if isinstance(url, basestring):
79            self.resource = http.Resource(url, session or http.Session())
80        else:
81            self.resource = url # treat as a Resource object
82        if not full_commit:
83            self.resource.headers['X-Couch-Full-Commit'] = 'false'
84
85    def __contains__(self, name):
86        """Return whether the server contains a database with the specified
87        name.
88
89        :param name: the database name
90        :return: `True` if a database with the name exists, `False` otherwise
91        """
92        try:
93            self.resource.head(validate_dbname(name))
94            return True
95        except http.ResourceNotFound:
96            return False
97
98    def __iter__(self):
99        """Iterate over the names of all databases."""
100        status, headers, data = self.resource.get_json('_all_dbs')
101        return iter(data)
102
103    def __len__(self):
104        """Return the number of databases."""
105        status, headers, data = self.resource.get_json('_all_dbs')
106        return len(data)
107
108    def __nonzero__(self):
109        """Return whether the server is available."""
110        try:
111            self.resource.head()
112            return True
113        except:
114            return False
115
116    def __repr__(self):
117        return '<%s %r>' % (type(self).__name__, self.resource.url)
118
119    def __delitem__(self, name):
120        """Remove the database with the specified name.
121
122        :param name: the name of the database
123        :raise ResourceNotFound: if no database with that name exists
124        """
125        self.resource.delete_json(validate_dbname(name))
126
127    def __getitem__(self, name):
128        """Return a `Database` object representing the database with the
129        specified name.
130
131        :param name: the name of the database
132        :return: a `Database` object representing the database
133        :rtype: `Database`
134        :raise ResourceNotFound: if no database with that name exists
135        """
136        db = Database(self.resource(name), validate_dbname(name))
137        db.resource.head() # actually make a request to the database
138        return db
139
140    def config(self):
141        """The configuration of the CouchDB server.
142
143        The configuration is represented as a nested dictionary of sections and
144        options from the configuration files of the server, or the default
145        values for options that are not explicitly configured.
146
147        :rtype: `dict`
148        """
149        status, headers, data = self.resource.get_json('_config')
150        return data
151
152    def version(self):
153        """The version string of the CouchDB server.
154
155        Note that this results in a request being made, and can also be used
156        to check for the availability of the server.
157
158        :rtype: `unicode`"""
159        status, headers, data = self.resource.get_json()
160        return data['version']
161
162    def stats(self, name=None):
163        """Server statistics.
164
165        :param name: name of single statistic, e.g. httpd/requests
166                     (None -- return all statistics)
167        """
168        if not name:
169            resource = self.resource('_stats')
170        else:
171            resource = self.resource('_stats', *name.split('/'))
172        status, headers, data = resource.get_json()
173        return data
174
175    def tasks(self):
176        """A list of tasks currently active on the server."""
177        status, headers, data = self.resource.get_json('_active_tasks')
178        return data
179
180    def uuids(self, count=None):
181        """Retrieve a batch of uuids
182
183        :param count: a number of uuids to fetch
184                      (None -- get as many as the server sends)
185        :return: a list of uuids
186        """
187        if count is None:
188            _, _, data = self.resource.get_json('_uuids')
189        else:
190            _, _, data = self.resource.get_json('_uuids', count=count)
191        return data['uuids']
192
193    def create(self, name):
194        """Create a new database with the given name.
195
196        :param name: the name of the database
197        :return: a `Database` object representing the created database
198        :rtype: `Database`
199        :raise PreconditionFailed: if a database with that name already exists
200        """
201        self.resource.put_json(validate_dbname(name))
202        return self[name]
203
204    def delete(self, name):
205        """Delete the database with the specified name.
206
207        :param name: the name of the database
208        :raise ResourceNotFound: if a database with that name does not exist
209        :since: 0.6
210        """
211        del self[name]
212
213    def replicate(self, source, target, **options):
214        """Replicate changes from the source database to the target database.
215
216        :param source: URL of the source database
217        :param target: URL of the target database
218        :param options: optional replication args, e.g. continuous=True
219        """
220        data = {'source': source, 'target': target}
221        data.update(options)
222        status, headers, data = self.resource.post_json('_replicate', data)
223        return data
224
225
226class Database(object):
227    """Representation of a database on a CouchDB server.
228
229    >>> server = Server()
230    >>> db = server.create('python-tests')
231
232    New documents can be added to the database using the `save()` method:
233
234    >>> doc_id, doc_rev = db.save({'type': 'Person', 'name': 'John Doe'})
235
236    This class provides a dictionary-like interface to databases: documents are
237    retrieved by their ID using item access
238
239    >>> doc = db[doc_id]
240    >>> doc                 #doctest: +ELLIPSIS
241    <Document '...'@... {...}>
242
243    Documents are represented as instances of the `Row` class, which is
244    basically just a normal dictionary with the additional attributes ``id`` and
245    ``rev``:
246
247    >>> doc.id, doc.rev     #doctest: +ELLIPSIS
248    ('...', ...)
249    >>> doc['type']
250    'Person'
251    >>> doc['name']
252    'John Doe'
253
254    To update an existing document, you use item access, too:
255
256    >>> doc['name'] = 'Mary Jane'
257    >>> db[doc.id] = doc
258
259    The `save()` method creates a document with a random ID generated by
260    CouchDB (which is not recommended). If you want to explicitly specify the
261    ID, you'd use item access just as with updating:
262
263    >>> db['JohnDoe'] = {'type': 'person', 'name': 'John Doe'}
264
265    >>> 'JohnDoe' in db
266    True
267    >>> len(db)
268    2
269
270    >>> del server['python-tests']
271    """
272
273    def __init__(self, url, name=None, session=None):
274        if isinstance(url, basestring):
275            if not url.startswith('http'):
276                url = DEFAULT_BASE_URL + url
277            self.resource = http.Resource(url, session)
278        else:
279            self.resource = url
280        self._name = name
281
282    def __repr__(self):
283        return '<%s %r>' % (type(self).__name__, self.name)
284
285    def __contains__(self, id):
286        """Return whether the database contains a document with the specified
287        ID.
288
289        :param id: the document ID
290        :return: `True` if a document with the ID exists, `False` otherwise
291        """
292        try:
293            _doc_resource(self.resource, id).head()
294            return True
295        except http.ResourceNotFound:
296            return False
297
298    def __iter__(self):
299        """Return the IDs of all documents in the database."""
300        return iter([item.id for item in self.view('_all_docs')])
301
302    def __len__(self):
303        """Return the number of documents in the database."""
304        _, _, data = self.resource.get_json()
305        return data['doc_count']
306
307    def __nonzero__(self):
308        """Return whether the database is available."""
309        try:
310            self.resource.head()
311            return True
312        except:
313            return False
314
315    def __delitem__(self, id):
316        """Remove the document with the specified ID from the database.
317
318        :param id: the document ID
319        """
320        resource = _doc_resource(self.resource, id)
321        status, headers, data = resource.head()
322        resource.delete_json(rev=headers['etag'].strip('"'))
323
324    def __getitem__(self, id):
325        """Return the document with the specified ID.
326
327        :param id: the document ID
328        :return: a `Row` object representing the requested document
329        :rtype: `Document`
330        """
331        _, _, data = _doc_resource(self.resource, id).get_json()
332        return Document(data)
333
334    def __setitem__(self, id, content):
335        """Create or update a document with the specified ID.
336
337        :param id: the document ID
338        :param content: the document content; either a plain dictionary for
339                        new documents, or a `Row` object for existing
340                        documents
341        """
342        resource = _doc_resource(self.resource, id)
343        status, headers, data = resource.put_json(body=content)
344        content.update({'_id': data['id'], '_rev': data['rev']})
345
346    @property
347    def name(self):
348        """The name of the database.
349
350        Note that this may require a request to the server unless the name has
351        already been cached by the `info()` method.
352
353        :rtype: basestring
354        """
355        if self._name is None:
356            self.info()
357        return self._name
358
359    def create(self, data):
360        """Create a new document in the database with a random ID that is
361        generated by the server.
362
363        Note that it is generally better to avoid the `create()` method and
364        instead generate document IDs on the client side. This is due to the
365        fact that the underlying HTTP ``POST`` method is not idempotent, and
366        an automatic retry due to a problem somewhere on the networking stack
367        may cause multiple documents being created in the database.
368
369        To avoid such problems you can generate a UUID on the client side.
370        Python (since version 2.5) comes with a ``uuid`` module that can be
371        used for this::
372
373            from uuid import uuid4
374            doc_id = uuid4().hex
375            db[doc_id] = {'type': 'person', 'name': 'John Doe'}
376
377        :param data: the data to store in the document
378        :return: the ID of the created document
379        :rtype: `unicode`
380        """
381        warnings.warn('Database.create is deprecated, please use Database.save instead [2010-04-13]',
382                      DeprecationWarning, stacklevel=2)
383        _, _, data = self.resource.post_json(body=data)
384        return data['id']
385
386    def multisave(self, docs, **options):
387        """Perform a bulk insertion of the given documents.
388        """
389        responses = []
390        for doc in docs:
391            id, rev = self.save(doc, **options)
392            responses.append((id, rev))
393
394    def save(self, doc, **options):
395        """Create a new document or update an existing document.
396
397        If doc has no _id then the server will allocate a random ID and a new
398        document will be created. Otherwise the doc's _id will be used to
399        identity the document to create or update. Trying to update an existing
400        document with an incorrect _rev will raise a ResourceConflict exception.
401
402        Note that it is generally better to avoid saving documents with no _id
403        and instead generate document IDs on the client side. This is due to
404        the fact that the underlying HTTP ``POST`` method is not idempotent,
405        and an automatic retry due to a problem somewhere on the networking
406        stack may cause multiple documents being created in the database.
407
408        To avoid such problems you can generate a UUID on the client side.
409        Python (since version 2.5) comes with a ``uuid`` module that can be
410        used for this::
411
412            from uuid import uuid4
413            doc = {'_id': uuid4().hex, 'type': 'person', 'name': 'John Doe'}
414            db.save(doc)
415
416        :param doc: the document to store
417        :param options: optional args, e.g. batch='ok'
418        :return: (id, rev) tuple of the save document
419        :rtype: `tuple`
420        """
421        if '_id' in doc:
422            func = _doc_resource(self.resource, doc['_id']).put_json
423        else:
424            func = self.resource.post_json
425        _, _, data = func(body=doc, **options)
426        id, rev = data['id'], data.get('rev')
427        doc['_id'] = id
428        if rev is not None: # Not present for batch='ok'
429            doc['_rev'] = rev
430        return id, rev
431
432    def cleanup(self):
433        """Clean up old design document indexes.
434
435        Remove all unused index files from the database storage area.
436
437        :return: a boolean to indicate successful cleanup initiation
438        :rtype: `bool`
439        """
440        headers = {'Content-Type': 'application/json'}
441        _, _, data = self.resource('_view_cleanup').post_json(headers=headers)
442        return data['ok']
443
444    def commit(self):
445        """If the server is configured to delay commits, or previous requests
446        used the special ``X-Couch-Full-Commit: false`` header to disable
447        immediate commits, this method can be used to ensure that any
448        non-committed changes are committed to physical storage.
449        """
450        _, _, data = self.resource.post_json(
451            '_ensure_full_commit',
452            headers={'Content-Type': 'application/json'})
453        return data
454
455    def compact(self, ddoc=None):
456        """Compact the database or a design document's index.
457
458        Without an argument, this will try to prune all old revisions from the
459        database. With an argument, it will compact the index cache for all
460        views in the design document specified.
461
462        :return: a boolean to indicate whether the compaction was initiated
463                 successfully
464        :rtype: `bool`
465        """
466        if ddoc:
467            resource = self.resource('_compact', ddoc)
468        else:
469            resource = self.resource('_compact')
470        _, _, data = resource.post_json(
471            headers={'Content-Type': 'application/json'})
472        return data['ok']
473
474    def copy(self, src, dest):
475        """Copy the given document to create a new document.
476
477        :param src: the ID of the document to copy, or a dictionary or
478                    `Document` object representing the source document.
479        :param dest: either the destination document ID as string, or a
480                     dictionary or `Document` instance of the document that
481                     should be overwritten.
482        :return: the new revision of the destination document
483        :rtype: `str`
484        :since: 0.6
485        """
486        if not isinstance(src, basestring):
487            if not isinstance(src, dict):
488                if hasattr(src, 'items'):
489                    src = dict(src.items())
490                else:
491                    raise TypeError('expected dict or string, got %s' %
492                                    type(src))
493            src = src['_id']
494
495        if not isinstance(dest, basestring):
496            if not isinstance(dest, dict):
497                if hasattr(dest, 'items'):
498                    dest = dict(dest.items())
499                else:
500                    raise TypeError('expected dict or string, got %s' %
501                                    type(dest))
502            if '_rev' in dest:
503                dest = '%s?%s' % (http.quote(dest['_id']),
504                                  http.urlencode({'rev': dest['_rev']}))
505            else:
506                dest = http.quote(dest['_id'])
507
508        _, _, data = self.resource._request('COPY', src,
509                                            headers={'Destination': dest})
510        data = json.decode(data.read())
511        return data['rev']
512
513    def delete(self, doc):
514        """Delete the given document from the database.
515
516        Use this method in preference over ``__del__`` to ensure you're
517        deleting the revision that you had previously retrieved. In the case
518        the document has been updated since it was retrieved, this method will
519        raise a `ResourceConflict` exception.
520
521        >>> server = Server()
522        >>> db = server.create('python-tests')
523
524        >>> doc = dict(type='Person', name='John Doe')
525        >>> db['johndoe'] = doc
526        >>> doc2 = db['johndoe']
527        >>> doc2['age'] = 42
528        >>> db['johndoe'] = doc2
529        >>> db.delete(doc)
530        Traceback (most recent call last):
531          ...
532        ResourceConflict: ('conflict', 'Document update conflict.')
533
534        >>> del server['python-tests']
535
536        :param doc: a dictionary or `Document` object holding the document data
537        :raise ResourceConflict: if the document was updated in the database
538        :since: 0.4.1
539        """
540        if doc['_id'] is None:
541            raise ValueError('document ID cannot be None')
542        _doc_resource(self.resource, doc['_id']).delete_json(rev=doc['_rev'])
543
544    def get(self, id, default=None, **options):
545        """Return the document with the specified ID.
546
547        :param id: the document ID
548        :param default: the default value to return when the document is not
549                        found
550        :return: a `Row` object representing the requested document, or `None`
551                 if no document with the ID was found
552        :rtype: `Document`
553        """
554        try:
555            _, _, data = _doc_resource(self.resource, id).get_json(**options)
556        except http.ResourceNotFound:
557            return default
558        if hasattr(data, 'items'):
559            return Document(data)
560        else:
561            return data
562
563    def revisions(self, id, **options):
564        """Return all available revisions of the given document.
565
566        :param id: the document ID
567        :return: an iterator over Document objects, each a different revision,
568                 in reverse chronological order, if any were found
569        """
570        try:
571            resource = _doc_resource(self.resource, id)
572            status, headers, data = resource.get_json(revs=True)
573        except http.ResourceNotFound:
574            return
575
576        startrev = data['_revisions']['start']
577        for index, rev in enumerate(data['_revisions']['ids']):
578            options['rev'] = '%d-%s' % (startrev - index, rev)
579            revision = self.get(id, **options)
580            if revision is None:
581                return
582            yield revision
583
584    def info(self, ddoc=None):
585        """Return information about the database or design document as a
586        dictionary.
587
588        Without an argument, returns database information. With an argument,
589        return information for the given design document.
590
591        The returned dictionary exactly corresponds to the JSON response to
592        a ``GET`` request on the database or design document's info URI.
593
594        :return: a dictionary of database properties
595        :rtype: ``dict``
596        :since: 0.4
597        """
598        if ddoc is not None:
599            _, _, data = self.resource('_design', ddoc, '_info').get_json()
600        else:
601            _, _, data = self.resource.get_json()
602            self._name = data['db_name']
603        return data
604
605    def delete_attachment(self, doc, filename):
606        """Delete the specified attachment.
607
608        Note that the provided `doc` is required to have a ``_rev`` field.
609        Thus, if the `doc` is based on a view row, the view row would need to
610        include the ``_rev`` field.
611
612        :param doc: the dictionary or `Document` object representing the
613                    document that the attachment belongs to
614        :param filename: the name of the attachment file
615        :since: 0.4.1
616        """
617        resource = _doc_resource(self.resource, doc['_id'])
618        _, _, data = resource.delete_json(filename, rev=doc['_rev'])
619        doc['_rev'] = data['rev']
620
621    def get_attachment(self, id_or_doc, filename, default=None):
622        """Return an attachment from the specified doc id and filename.
623
624        :param id_or_doc: either a document ID or a dictionary or `Document`
625                          object representing the document that the attachment
626                          belongs to
627        :param filename: the name of the attachment file
628        :param default: default value to return when the document or attachment
629                        is not found
630        :return: a file-like object with read and close methods, or the value
631                 of the `default` argument if the attachment is not found
632        :since: 0.4.1
633        """
634        if isinstance(id_or_doc, basestring):
635            id = id_or_doc
636        else:
637            id = id_or_doc['_id']
638        try:
639            _, _, data = _doc_resource(self.resource, id).get(filename)
640            return data
641        except http.ResourceNotFound:
642            return default
643
644    def put_attachment(self, doc, content, filename=None, content_type=None):
645        """Create or replace an attachment.
646
647        Note that the provided `doc` is required to have a ``_rev`` field. Thus,
648        if the `doc` is based on a view row, the view row would need to include
649        the ``_rev`` field.
650
651        :param doc: the dictionary or `Document` object representing the
652                    document that the attachment should be added to
653        :param content: the content to upload, either a file-like object or
654                        a string
655        :param filename: the name of the attachment file; if omitted, this
656                         function tries to get the filename from the file-like
657                         object passed as the `content` argument value
658        :param content_type: content type of the attachment; if omitted, the
659                             MIME type is guessed based on the file name
660                             extension
661        :since: 0.4.1
662        """
663        if filename is None:
664            if hasattr(content, 'name'):
665                filename = os.path.basename(content.name)
666            else:
667                raise ValueError('no filename specified for attachment')
668        if content_type is None:
669            content_type = ';'.join(
670                filter(None, mimetypes.guess_type(filename))
671            )
672
673        resource = _doc_resource(self.resource, doc['_id'])
674        status, headers, data = resource.put_json(filename, body=content, headers={
675            'Content-Type': content_type
676        }, rev=doc['_rev'])
677        doc['_rev'] = data['rev']
678
679    def query(self, map_fun, reduce_fun=None, language='javascript',
680              wrapper=None, **options):
681        """Execute an ad-hoc query (a "temp view") against the database.
682
683        >>> server = Server()
684        >>> db = server.create('python-tests')
685        >>> db['johndoe'] = dict(type='Person', name='John Doe')
686        >>> db['maryjane'] = dict(type='Person', name='Mary Jane')
687        >>> db['gotham'] = dict(type='City', name='Gotham City')
688        >>> map_fun = '''function(doc) {
689        ...     if (doc.type == 'Person')
690        ...         emit(doc.name, null);
691        ... }'''
692        >>> for row in db.query(map_fun):
693        ...     print row.key
694        John Doe
695        Mary Jane
696
697        >>> for row in db.query(map_fun, descending=True):
698        ...     print row.key
699        Mary Jane
700        John Doe
701
702        >>> for row in db.query(map_fun, key='John Doe'):
703        ...     print row.key
704        John Doe
705
706        >>> del server['python-tests']
707
708        :param map_fun: the code of the map function
709        :param reduce_fun: the code of the reduce function (optional)
710        :param language: the language of the functions, to determine which view
711                         server to use
712        :param wrapper: an optional callable that should be used to wrap the
713                        result rows
714        :param options: optional query string parameters
715        :return: the view reults
716        :rtype: `ViewResults`
717        """
718        return TemporaryView(self.resource('_temp_view'), map_fun,
719                             reduce_fun, language=language,
720                             wrapper=wrapper)(**options)
721
722    def update(self, documents, **options):
723        """Perform a bulk update or insertion of the given documents using a
724        single HTTP request.
725
726        >>> server = Server()
727        >>> db = server.create('python-tests')
728        >>> for doc in db.update([
729        ...     Document(type='Person', name='John Doe'),
730        ...     Document(type='Person', name='Mary Jane'),
731        ...     Document(type='City', name='Gotham City')
732        ... ]):
733        ...     print repr(doc) #doctest: +ELLIPSIS
734        (True, '...', '...')
735        (True, '...', '...')
736        (True, '...', '...')
737
738        >>> del server['python-tests']
739
740        The return value of this method is a list containing a tuple for every
741        element in the `documents` sequence. Each tuple is of the form
742        ``(success, docid, rev_or_exc)``, where ``success`` is a boolean
743        indicating whether the update succeeded, ``docid`` is the ID of the
744        document, and ``rev_or_exc`` is either the new document revision, or
745        an exception instance (e.g. `ResourceConflict`) if the update failed.
746
747        If an object in the documents list is not a dictionary, this method
748        looks for an ``items()`` method that can be used to convert the object
749        to a dictionary. Effectively this means you can also use this method
750        with `mapping.Document` objects.
751
752        :param documents: a sequence of dictionaries or `Document` objects, or
753                          objects providing a ``items()`` method that can be
754                          used to convert them to a dictionary
755        :return: an iterable over the resulting documents
756        :rtype: ``list``
757
758        :since: version 0.2
759        """
760        docs = []
761        for doc in documents:
762            if isinstance(doc, dict):
763                docs.append(doc)
764            elif hasattr(doc, 'items'):
765                docs.append(dict(doc.items()))
766            else:
767                raise TypeError('expected dict, got %s' % type(doc))
768
769        content = options
770        content.update(docs=docs)
771        _, _, data = self.resource.post_json('_bulk_docs', body=content)
772
773        results = []
774        for idx, result in enumerate(data):
775            if 'error' in result:
776                if result['error'] == 'conflict':
777                    exc_type = http.ResourceConflict
778                else:
779                    # XXX: Any other error types mappable to exceptions here?
780                    exc_type = http.ServerError
781                results.append((False, result['id'],
782                                exc_type(result['reason'])))
783            else:
784                doc = documents[idx]
785                if isinstance(doc, dict): # XXX: Is this a good idea??
786                    doc.update({'_id': result['id'], '_rev': result['rev']})
787                results.append((True, result['id'], result['rev']))
788
789        return results
790
791    def purge(self, docs):
792        """Perform purging (complete removing) of the given documents.
793
794        Uses a single HTTP request to purge all given documents. Purged
795        documents do not leave any meta-data in the storage and are not
796        replicated.
797        """
798        content = {}
799        for doc in docs:
800            if isinstance(doc, dict):
801                content[doc['_id']] = [doc['_rev']]
802            elif hasattr(doc, 'items'):
803                doc = dict(doc.items())
804                content[doc['_id']] = [doc['_rev']]
805            else:
806                raise TypeError('expected dict, got %s' % type(doc))
807        _, _, data = self.resource.post_json('_purge', body=content)
808        return data
809
810    def view(self, name, wrapper=None, **options):
811        """Execute a predefined view.
812
813        >>> server = Server()
814        >>> db = server.create('python-tests')
815        >>> db['gotham'] = dict(type='City', name='Gotham City')
816
817        >>> for row in db.view('_all_docs'):
818        ...     print row.id
819        gotham
820
821        >>> del server['python-tests']
822
823        :param name: the name of the view; for custom views, use the format
824                     ``design_docid/viewname``, that is, the document ID of the
825                     design document and the name of the view, separated by a
826                     slash
827        :param wrapper: an optional callable that should be used to wrap the
828                        result rows
829        :param options: optional query string parameters
830        :return: the view results
831        :rtype: `ViewResults`
832        """
833        path = _path_from_name(name, '_view')
834        return PermanentView(self.resource(*path), '/'.join(path),
835                             wrapper=wrapper)(**options)
836
837    def show(self, name, docid=None, **options):
838        """Call a 'show' function.
839
840        :param name: the name of the show function in the format
841                     ``designdoc/showname``
842        :param docid: optional ID of a document to pass to the show function.
843        :param options: optional query string parameters
844        :return: (headers, body) tuple, where headers is a dict of headers
845                 returned from the show function and body is a readable
846                 file-like instance
847        """
848        path = _path_from_name(name, '_show')
849        if docid:
850            path.append(docid)
851        status, headers, body = self.resource(*path).get(**options)
852        return headers, body
853
854    def list(self, name, view, **options):
855        """Format a view using a 'list' function.
856
857        :param name: the name of the list function in the format
858                     ``designdoc/listname``
859        :param view: the name of the view in the format ``designdoc/viewname``
860        :param options: optional query string parameters
861        :return: (headers, body) tuple, where headers is a dict of headers
862                 returned from the list function and body is a readable
863                 file-like instance
864        """
865        path = _path_from_name(name, '_list')
866        path.extend(view.split('/', 1))
867        _, headers, body = _call_viewlike(self.resource(*path), options)
868        return headers, body
869
870    def update_doc(self, name, docid=None, **options):
871        """Calls server side update handler.
872
873        :param name: the name of the update handler function in the format
874                     ``designdoc/updatename``.
875        :param docid: optional ID of a document to pass to the update handler.
876        :param options: optional query string parameters.
877        :return: (headers, body) tuple, where headers is a dict of headers
878                 returned from the list function and body is a readable
879                 file-like instance
880        """
881        path = _path_from_name(name, '_update')
882        if docid is None:
883            func = self.resource(*path).post
884        else:
885            path.append(docid)
886            func = self.resource(*path).put
887        _, headers, body = func(**options)
888        return headers, body
889
890    def _changes(self, **opts):
891        _, _, data = self.resource.get('_changes', **opts)
892        lines = iter(data)
893        for ln in lines:
894            if not ln: # skip heartbeats
895                continue
896            doc = json.decode(ln)
897            if 'last_seq' in doc: # consume the rest of the response if this
898                for ln in lines:  # was the last line, allows conn reuse
899                    pass
900            yield doc
901
902    def changes(self, **opts):
903        """Retrieve a changes feed from the database.
904
905        Takes since, feed, heartbeat and timeout options.
906        """
907        if opts.get('feed') == 'continuous':
908            return self._changes(**opts)
909        _, _, data = self.resource.get_json('_changes', **opts)
910        return data
911
912
913def _doc_resource(base, doc_id):
914    """Return the resource for the given document id.
915    """
916    # Split an id that starts with a reserved segment, e.g. _design/foo, so
917    # that the / that follows the 1st segment does not get escaped.
918    if doc_id[:1] == '_':
919        return base(*doc_id.split('/', 1))
920    return base(doc_id)
921
922
923def _path_from_name(name, type):
924    """Expand a 'design/foo' style name to its full path as a list of
925    segments.
926    """
927    if name.startswith('_'):
928        return name.split('/')
929    design, name = name.split('/', 1)
930    return ['_design', design, type, name]
931
932
933class Document(dict):
934    """Representation of a document in the database.
935
936    This is basically just a dictionary with the two additional properties
937    `id` and `rev`, which contain the document ID and revision, respectively.
938    """
939
940    def __repr__(self):
941        return '<%s %r@%r %r>' % (type(self).__name__, self.id, self.rev,
942                                  dict([(k,v) for k,v in self.items()
943                                        if k not in ('_id', '_rev')]))
944
945    @property
946    def id(self):
947        """The document ID.
948
949        :rtype: basestring
950        """
951        return self['_id']
952
953    @property
954    def rev(self):
955        """The document revision.
956
957        :rtype: basestring
958        """
959        return self['_rev']
960
961
962class View(object):
963    """Abstract representation of a view or query."""
964
965    def __init__(self, url, wrapper=None, session=None):
966        if isinstance(url, basestring):
967            self.resource = http.Resource(url, session)
968        else:
969            self.resource = url
970        self.wrapper = wrapper
971
972    def __call__(self, **options):
973        return ViewResults(self, options)
974
975    def __iter__(self):
976        return iter(self())
977
978    def _exec(self, options):
979        raise NotImplementedError
980
981
982class PermanentView(View):
983    """Representation of a permanent view on the server."""
984
985    def __init__(self, uri, name, wrapper=None, session=None):
986        View.__init__(self, uri, wrapper=wrapper, session=session)
987        self.name = name
988
989    def __repr__(self):
990        return '<%s %r>' % (type(self).__name__, self.name)
991
992    def _exec(self, options):
993        _, _, data = _call_viewlike(self.resource, options)
994        return data
995
996
997class TemporaryView(View):
998    """Representation of a temporary view."""
999
1000    def __init__(self, uri, map_fun, reduce_fun=None,
1001                 language='javascript', wrapper=None, session=None):
1002        View.__init__(self, uri, wrapper=wrapper, session=session)
1003        if isinstance(map_fun, FunctionType):
1004            map_fun = getsource(map_fun).rstrip('\n\r')
1005        self.map_fun = dedent(map_fun.lstrip('\n\r'))
1006        if isinstance(reduce_fun, FunctionType):
1007            reduce_fun = getsource(reduce_fun).rstrip('\n\r')
1008        if reduce_fun:
1009            reduce_fun = dedent(reduce_fun.lstrip('\n\r'))
1010        self.reduce_fun = reduce_fun
1011        self.language = language
1012
1013    def __repr__(self):
1014        return '<%s %r %r>' % (type(self).__name__, self.map_fun,
1015                               self.reduce_fun)
1016
1017    def _exec(self, options):
1018        body = {'map': self.map_fun, 'language': self.language}
1019        if self.reduce_fun:
1020            body['reduce'] = self.reduce_fun
1021        if 'keys' in options:
1022            options = options.copy()
1023            body['keys'] = options.pop('keys')
1024        content = json.encode(body).encode('utf-8')
1025        _, _, data = self.resource.post_json(body=content, headers={
1026            'Content-Type': 'application/json'
1027        }, **_encode_view_options(options))
1028        return data
1029
1030
1031def _encode_view_options(options):
1032    """Encode any items in the options dict that are sent as a JSON string to a
1033    view/list function.
1034    """
1035    retval = {}
1036    for name, value in options.items():
1037        if name in ('key', 'startkey', 'endkey') \
1038                or not isinstance(value, basestring):
1039            value = json.encode(value)
1040        retval[name] = value
1041    return retval
1042
1043
1044def _call_viewlike(resource, options):
1045    """Call a resource that takes view-like options.
1046    """
1047    if 'keys' in options:
1048        options = options.copy()
1049        keys = {'keys': options.pop('keys')}
1050        return resource.post_json(body=keys, **_encode_view_options(options))
1051    else:
1052        return resource.get_json(**_encode_view_options(options))
1053
1054
1055class ViewResults(object):
1056    """Representation of a parameterized view (either permanent or temporary)
1057    and the results it produces.
1058
1059    This class allows the specification of ``key``, ``startkey``, and
1060    ``endkey`` options using Python slice notation.
1061
1062    >>> server = Server()
1063    >>> db = server.create('python-tests')
1064    >>> db['johndoe'] = dict(type='Person', name='John Doe')
1065    >>> db['maryjane'] = dict(type='Person', name='Mary Jane')
1066    >>> db['gotham'] = dict(type='City', name='Gotham City')
1067    >>> map_fun = '''function(doc) {
1068    ...     emit([doc.type, doc.name], doc.name);
1069    ... }'''
1070    >>> results = db.query(map_fun)
1071
1072    At this point, the view has not actually been accessed yet. It is accessed
1073    as soon as it is iterated over, its length is requested, or one of its
1074    `rows`, `total_rows`, or `offset` properties are accessed:
1075
1076    >>> len(results)
1077    3
1078
1079    You can use slices to apply ``startkey`` and/or ``endkey`` options to the
1080    view:
1081
1082    >>> people = results[['Person']:['Person','ZZZZ']]
1083    >>> for person in people:
1084    ...     print person.value
1085    John Doe
1086    Mary Jane
1087    >>> people.total_rows, people.offset
1088    (3, 1)
1089
1090    Use plain indexed notation (without a slice) to apply the ``key`` option.
1091    Note that as CouchDB makes no claim that keys are unique in a view, this
1092    can still return multiple rows:
1093
1094    >>> list(results[['City', 'Gotham City']])
1095    [<Row id='gotham', key=['City', 'Gotham City'], value='Gotham City'>]
1096
1097    >>> del server['python-tests']
1098    """
1099
1100    def __init__(self, view, options):
1101        self.view = view
1102        self.options = options
1103        self._rows = self._total_rows = self._offset = None
1104
1105    def __repr__(self):
1106        return '<%s %r %r>' % (type(self).__name__, self.view, self.options)
1107
1108    def __getitem__(self, key):
1109        options = self.options.copy()
1110        if type(key) is slice:
1111            if key.start is not None:
1112                options['startkey'] = key.start
1113            if key.stop is not None:
1114                options['endkey'] = key.stop
1115            return ViewResults(self.view, options)
1116        else:
1117            options['key'] = key
1118            return ViewResults(self.view, options)
1119
1120    def __iter__(self):
1121        return iter(self.rows)
1122
1123    def __len__(self):
1124        return len(self.rows)
1125
1126    def _fetch(self):
1127        data = self.view._exec(self.options)
1128        wrapper = self.view.wrapper or Row
1129        self._rows = [wrapper(row) for row in data['rows']]
1130        self._total_rows = data.get('total_rows')
1131        self._offset = data.get('offset', 0)
1132
1133    @property
1134    def rows(self):
1135        """The list of rows returned by the view.
1136
1137        :rtype: `list`
1138        """
1139        if self._rows is None:
1140            self._fetch()
1141        return self._rows
1142
1143    @property
1144    def total_rows(self):
1145        """The total number of rows in this view.
1146
1147        This value is `None` for reduce views.
1148
1149        :rtype: `int` or ``NoneType`` for reduce views
1150        """
1151        if self._rows is None:
1152            self._fetch()
1153        return self._total_rows
1154
1155    @property
1156    def offset(self):
1157        """The offset of the results from the first row in the view.
1158
1159        This value is 0 for reduce views.
1160
1161        :rtype: `int`
1162        """
1163        if self._rows is None:
1164            self._fetch()
1165        return self._offset
1166
1167
1168class Row(dict):
1169    """Representation of a row as returned by database views."""
1170
1171    def __repr__(self):
1172        keys = 'id', 'key', 'error', 'value'
1173        items = ['%s=%r' % (k, self[k]) for k in keys if k in self]
1174        return '<%s %s>' % (type(self).__name__, ', '.join(items))
1175
1176    @property
1177    def id(self):
1178        """The associated Document ID if it exists. Returns `None` when it
1179        doesn't (reduce results).
1180        """
1181        return self.get('id')
1182
1183    @property
1184    def key(self):
1185        return self['key']
1186
1187    @property
1188    def value(self):
1189        return self.get('value')
1190
1191    @property
1192    def error(self):
1193        return self.get('error')
1194
1195    @property
1196    def doc(self):
1197        """The associated document for the row. This is only present when the
1198        view was accessed with ``include_docs=True`` as a query parameter,
1199        otherwise this property will be `None`.
1200        """
1201        doc = self.get('doc')
1202        if doc:
1203            return Document(doc)
1204
1205
1206SPECIAL_DB_NAMES = set(['_users'])
1207VALID_DB_NAME = re.compile(r'^[a-z][a-z0-9_$()+-/]*$')
1208def validate_dbname(name):
1209    if name in SPECIAL_DB_NAMES:
1210        return name
1211    if not VALID_DB_NAME.match(name):
1212        raise ValueError('Invalid database name')
1213    return name
1214