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