├── couchdb ├── tests │ ├── __init__.py │ ├── _loader │ │ ├── _id │ │ ├── language │ │ ├── filters.xml │ │ ├── filters │ │ │ └── filter.js │ │ ├── language.xml │ │ └── views │ │ │ └── a │ │ │ └── map.js │ ├── package.py │ ├── __main__.py │ ├── tools.py │ ├── testutil.py │ ├── design.py │ ├── loader.py │ ├── couchhttp.py │ ├── view.py │ ├── multipart.py │ ├── couch_tests.py │ └── mapping.py ├── __main__.py ├── tools │ ├── __init__.py │ ├── load.py │ ├── dump.py │ └── replicate.py ├── util.py ├── util3.py ├── util2.py ├── __init__.py ├── loader.py ├── json.py ├── view.py ├── design.py ├── multipart.py ├── mapping.py └── http.py ├── doc ├── changes.rst ├── mapping.rst ├── client.rst ├── views.rst ├── index.rst ├── getting-started.rst ├── Makefile └── conf.py ├── .coveragerc ├── .gitignore ├── MANIFEST.in ├── .travis.yml ├── setup.cfg ├── tox.ini ├── Makefile ├── RELEASING.rst ├── COPYING ├── perftest.py ├── README.rst ├── setup.py └── ChangeLog.rst /couchdb/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /couchdb/tests/_loader/_id: -------------------------------------------------------------------------------- 1 | _design/loader 2 | -------------------------------------------------------------------------------- /couchdb/tests/_loader/language: -------------------------------------------------------------------------------- 1 | javascript 2 | -------------------------------------------------------------------------------- /couchdb/tests/_loader/filters.xml: -------------------------------------------------------------------------------- 1 |

Assert clobber of 'filters' directory

-------------------------------------------------------------------------------- /doc/changes.rst: -------------------------------------------------------------------------------- 1 | Changes 2 | ======= 3 | 4 | .. include:: ../ChangeLog.rst 5 | -------------------------------------------------------------------------------- /couchdb/tests/_loader/filters/filter.js: -------------------------------------------------------------------------------- 1 | function(doc, req) { return true; } 2 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = couchdb 3 | 4 | [report] 5 | omit = couchdb/tests/* 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **.pyc 2 | *.egg* 3 | build/* 4 | dist/* 5 | doc/build/* 6 | .tox 7 | .coverage 8 | -------------------------------------------------------------------------------- /couchdb/__main__.py: -------------------------------------------------------------------------------- 1 | from . import view 2 | 3 | if __name__ == '__main__': 4 | view.main() 5 | -------------------------------------------------------------------------------- /couchdb/tests/_loader/language.xml: -------------------------------------------------------------------------------- 1 |

Assert clobber of 'language' (without an extension)

2 | -------------------------------------------------------------------------------- /couchdb/tests/_loader/views/a/map.js: -------------------------------------------------------------------------------- 1 | function(doc) { 2 | emit(doc.property_to_index); 3 | } 4 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include COPYING 2 | include Makefile 3 | include ChangeLog.rst 4 | include doc/conf.py 5 | include doc/*.rst 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 2.7 3 | services: 4 | - couchdb 5 | env: 6 | - TOX_ENV=py27 7 | - TOX_ENV=py34 8 | - TOX_ENV=py34-json 9 | install: 10 | - pip install tox 11 | script: 12 | - tox -e $TOX_ENV 13 | -------------------------------------------------------------------------------- /couchdb/tools/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2008 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 | -------------------------------------------------------------------------------- /couchdb/util.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | if sys.version_info[0] < 3: 4 | from couchdb.util2 import * 5 | else: 6 | from couchdb.util3 import * 7 | 8 | def pyexec(code, gns, lns): 9 | # http://bugs.python.org/issue21591 10 | exec(code, gns, lns) 11 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [egg_info] 2 | tag_build = dev 3 | tag_svn_revision = true 4 | 5 | [build_sphinx] 6 | source-dir = doc/ 7 | build-dir = doc/build 8 | all_files = 1 9 | 10 | [bdist_wheel] 11 | universal = 1 12 | 13 | [upload_sphinx] 14 | upload-dir = doc/build/html 15 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27, py34, py34-json, py35-json 3 | 4 | [testenv] 5 | deps = simplejson 6 | commands = 7 | python --version 8 | {envbindir}/python -m couchdb.tests 9 | 10 | [testenv:py34-json] 11 | basepython = python3.4 12 | deps = 13 | 14 | [testenv:py35-json] 15 | basepython = python3.5 16 | deps = 17 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test doc upload-doc 2 | 3 | test: 4 | tox 5 | 6 | test2: 7 | PYTHONPATH=. python -m couchdb.tests 8 | 9 | test3: 10 | PYTHONPATH=. python3 -m couchdb.tests 11 | 12 | doc: 13 | python setup.py build_sphinx 14 | 15 | upload-doc: doc 16 | python2 setup.py upload_sphinx 17 | 18 | coverage: 19 | PYTHONPATH=. coverage run couchdb/tests/__main__.py 20 | coverage report 21 | -------------------------------------------------------------------------------- /couchdb/util3.py: -------------------------------------------------------------------------------- 1 | 2 | __all__ = [ 3 | 'StringIO', 'urlsplit', 'urlunsplit', 'urlquote', 'urlunquote', 4 | 'urlencode', 'utype', 'btype', 'ltype', 'strbase', 'funcode', 'urlparse', 5 | ] 6 | 7 | utype = str 8 | btype = bytes 9 | ltype = int 10 | strbase = str, bytes 11 | 12 | from io import BytesIO as StringIO 13 | from urllib.parse import urlsplit, urlunsplit, urlencode, urlparse 14 | from urllib.parse import quote as urlquote 15 | from urllib.parse import unquote as urlunquote 16 | 17 | 18 | def funcode(fun): 19 | return fun.__code__ 20 | -------------------------------------------------------------------------------- /doc/mapping.rst: -------------------------------------------------------------------------------- 1 | Mapping CouchDB documents to Python objects: couchdb.mapping 2 | ============================================================ 3 | 4 | .. automodule:: couchdb.mapping 5 | 6 | Field types 7 | ----------- 8 | 9 | .. autoclass:: TextField 10 | .. autoclass:: FloatField 11 | .. autoclass:: IntegerField 12 | .. autoclass:: LongField 13 | .. autoclass:: BooleanField 14 | .. autoclass:: DecimalField 15 | .. autoclass:: DateField 16 | .. autoclass:: DateTimeField 17 | .. autoclass:: DictField 18 | .. autoclass:: ListField 19 | .. autoclass:: ViewField 20 | -------------------------------------------------------------------------------- /doc/client.rst: -------------------------------------------------------------------------------- 1 | Basic CouchDB API: couchdb.client 2 | ================================= 3 | 4 | .. automodule:: couchdb.client 5 | 6 | 7 | Server 8 | ------ 9 | 10 | .. autoclass:: Server 11 | :members: 12 | 13 | 14 | Database 15 | -------- 16 | 17 | .. autoclass:: Database 18 | :members: 19 | 20 | 21 | Document 22 | -------- 23 | 24 | .. autoclass:: Document 25 | :members: 26 | 27 | 28 | ViewResults 29 | ----------- 30 | 31 | .. autoclass:: ViewResults 32 | :members: 33 | 34 | 35 | Row 36 | --- 37 | 38 | .. autoclass:: Row 39 | :members: 40 | -------------------------------------------------------------------------------- /couchdb/util2.py: -------------------------------------------------------------------------------- 1 | 2 | __all__ = [ 3 | 'StringIO', 'urlsplit', 'urlunsplit', 'urlquote', 'urlunquote', 4 | 'urlencode', 'utype', 'btype', 'ltype', 'strbase', 'funcode', 'urlparse', 5 | ] 6 | 7 | utype = unicode 8 | btype = str 9 | ltype = long 10 | strbase = str, bytes, unicode 11 | 12 | from io import BytesIO as StringIO 13 | from urlparse import urlparse, urlsplit, urlunsplit 14 | from urllib import quote as urlquote 15 | from urllib import unquote as urlunquote 16 | from urllib import urlencode 17 | 18 | 19 | def funcode(fun): 20 | return fun.func_code 21 | -------------------------------------------------------------------------------- /RELEASING.rst: -------------------------------------------------------------------------------- 1 | Release procedure 2 | ================= 3 | 4 | A list of steps to perform when releasing. 5 | 6 | * Run tests against latest CouchDB release (ideally also trunk) 7 | * Make sure the version number in setup.py is correct 8 | * Update ChangeLog and add a release date, then commit 9 | * Edit setup.cfg, remove the egg_info section and commit 10 | * Tag the just-committed changeset 11 | * python setup.py bdist_wheel sdist --formats=gztar upload 12 | * Revert the setup.cfg change 13 | * Update the version number in setup.py 14 | * Upload docs to PyPI with ``make upload-doc`` 15 | -------------------------------------------------------------------------------- /couchdb/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2007 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 | from .client import Database, Document, Server 10 | from .http import HTTPError, PreconditionFailed, Resource, \ 11 | ResourceConflict, ResourceNotFound, ServerError, Session, \ 12 | Unauthorized, Forbidden 13 | 14 | try: 15 | __version__ = __import__('pkg_resources').get_distribution('CouchDB').version 16 | except: 17 | __version__ = '?' 18 | -------------------------------------------------------------------------------- /doc/views.rst: -------------------------------------------------------------------------------- 1 | Writing views in Python 2 | ======================= 3 | 4 | The couchdb-python package comes with a view server to allow you to write 5 | views in Python instead of JavaScript. When couchdb-python is installed, it 6 | will install a script called couchpy that runs the view server. To enable 7 | this for your CouchDB server, add the following section to local.ini:: 8 | 9 | [query_servers] 10 | python=/usr/bin/couchpy 11 | 12 | After restarting CouchDB, the Futon view editor should show ``python`` in 13 | the language pull-down menu. Here's some sample view code to get you started:: 14 | 15 | def fun(doc): 16 | if 'date' in doc: 17 | yield doc['date'], doc 18 | 19 | Note that the ``map`` function uses the Python ``yield`` keyword to emit 20 | values, where JavaScript views use an ``emit()`` function. 21 | -------------------------------------------------------------------------------- /couchdb/tests/package.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import unittest 4 | import couchdb 5 | 6 | class PackageTestCase(unittest.TestCase): 7 | 8 | def test_exports(self): 9 | expected = set([ 10 | # couchdb.client 11 | 'Server', 'Database', 'Document', 12 | # couchdb.http 13 | 'HTTPError', 'PreconditionFailed', 'ResourceNotFound', 14 | 'ResourceConflict', 'ServerError', 'Unauthorized', 'Forbidden', 15 | 'Resource', 'Session' 16 | ]) 17 | exported = set(e for e in dir(couchdb) if not e.startswith('_')) 18 | self.assertTrue(expected <= exported) 19 | 20 | 21 | def suite(): 22 | suite = unittest.TestSuite() 23 | suite.addTest(unittest.makeSuite(PackageTestCase, 'test')) 24 | return suite 25 | 26 | 27 | if __name__ == '__main__': 28 | unittest.main(defaultTest='suite') 29 | -------------------------------------------------------------------------------- /couchdb/tests/__main__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2007 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 | import unittest 10 | 11 | from couchdb.tests import client, couch_tests, design, couchhttp, \ 12 | multipart, mapping, view, package, tools, \ 13 | loader 14 | 15 | 16 | def suite(): 17 | suite = unittest.TestSuite() 18 | suite.addTest(client.suite()) 19 | suite.addTest(design.suite()) 20 | suite.addTest(couchhttp.suite()) 21 | suite.addTest(multipart.suite()) 22 | suite.addTest(mapping.suite()) 23 | suite.addTest(view.suite()) 24 | suite.addTest(couch_tests.suite()) 25 | suite.addTest(package.suite()) 26 | suite.addTest(tools.suite()) 27 | suite.addTest(loader.suite()) 28 | return suite 29 | 30 | 31 | if __name__ == '__main__': 32 | unittest.main(defaultTest='suite') 33 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | .. -*- mode: rst; encoding: utf-8 -*- 2 | .. couchdb-python documentation master file, created by 3 | sphinx-quickstart on Thu Apr 29 18:32:43 2010. 4 | You can adapt this file completely to your liking, but it should at least 5 | contain the root `toctree` directive. 6 | 7 | Introduction 8 | ============ 9 | 10 | ``couchdb`` is Python package for working with CouchDB_ from Python code. 11 | It consists of the following main modules: 12 | 13 | * ``couchdb.client``: This is the client library for interfacing CouchDB 14 | servers. If you don't know where to start, this is likely to be what you're 15 | looking for. 16 | 17 | * ``couchdb.mapping``: This module provides advanced mapping between CouchDB 18 | JSON documents and Python objects. 19 | 20 | Additionally, the ``couchdb.view`` module implements a view server for 21 | views written in Python. 22 | 23 | There may also be more information on the `project website`_. 24 | 25 | .. _couchdb: http://couchdb.org/ 26 | .. _project website: https://github.com/djc/couchdb-python 27 | .. _views written in Python: views 28 | 29 | Documentation 30 | ============= 31 | 32 | .. toctree:: 33 | :maxdepth: 2 34 | :numbered: 35 | 36 | getting-started.rst 37 | views.rst 38 | client.rst 39 | mapping.rst 40 | changes.rst 41 | 42 | Indices and tables 43 | ================== 44 | 45 | * :ref:`genindex` 46 | * :ref:`modindex` 47 | * :ref:`search` 48 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | Copyright (C) 2007-2008 Christopher Lenz 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions 6 | are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in 12 | the documentation and/or other materials provided with the 13 | distribution. 14 | 3. The name of the author may not be used to endorse or promote 15 | products derived from this software without specific prior 16 | written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS 19 | OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 21 | ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 22 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE 24 | GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 25 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER 26 | IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 27 | OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN 28 | IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /couchdb/tests/tools.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2012 Alexander Shorin 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 | 10 | 11 | import unittest 12 | 13 | from couchdb.util import StringIO 14 | from couchdb import Unauthorized 15 | from couchdb.tools import load, dump 16 | from couchdb.tests import testutil 17 | 18 | 19 | class ToolLoadTestCase(testutil.TempDatabaseMixin, unittest.TestCase): 20 | 21 | def test_handle_credentials(self): 22 | # Issue 194: couchdb-load attribute error: 'Resource' object has no attribute 'http' 23 | # http://code.google.com/p/couchdb-python/issues/detail?id=194 24 | load.load_db(StringIO(b''), self.db.resource.url, 'foo', 'bar') 25 | 26 | 27 | class ToolDumpTestCase(testutil.TempDatabaseMixin, unittest.TestCase): 28 | 29 | def test_handle_credentials(self): 30 | # Similar to issue 194 31 | # Fixing: AttributeError: 'Resource' object has no attribute 'http' 32 | try: 33 | dump.dump_db(self.db.resource.url, 'foo', 'bar', output=StringIO()) 34 | except Unauthorized: 35 | # This is ok, since we provided dummy credentials. 36 | pass 37 | 38 | 39 | def suite(): 40 | suite = unittest.TestSuite() 41 | suite.addTest(unittest.makeSuite(ToolLoadTestCase, 'test')) 42 | return suite 43 | 44 | 45 | if __name__ == '__main__': 46 | unittest.main(defaultTest='suite') 47 | 48 | -------------------------------------------------------------------------------- /perftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simple peformance tests. 3 | """ 4 | 5 | import sys 6 | import time 7 | 8 | import couchdb 9 | 10 | 11 | def main(): 12 | 13 | print 'sys.version : %r' % (sys.version,) 14 | print 'sys.platform : %r' % (sys.platform,) 15 | 16 | tests = [create_doc, create_bulk_docs] 17 | if len(sys.argv) > 1: 18 | tests = [test for test in tests if test.__name__ in sys.argv[1:]] 19 | 20 | server = couchdb.Server() 21 | for test in tests: 22 | _run(server, test) 23 | 24 | 25 | def _run(server, func): 26 | """Run a test in a clean db and log its execution time.""" 27 | sys.stdout.write("* [%s] %s ... " % (func.__name__, func.__doc__.strip())) 28 | sys.stdout.flush() 29 | db_name = 'couchdb-python/perftest' 30 | db = server.create(db_name) 31 | try: 32 | try: 33 | start = time.time() 34 | func(db) 35 | stop = time.time() 36 | sys.stdout.write("%0.2fs\n" % (stop - start,)) 37 | sys.stdout.flush() 38 | except Exception as e: 39 | sys.stdout.write("FAILED - %r\n" % (unicode(e),)) 40 | sys.stdout.flush() 41 | finally: 42 | server.delete(db_name) 43 | 44 | 45 | def create_doc(db): 46 | """Create lots of docs, one at a time""" 47 | for i in range(1000): 48 | db.save({'_id': unicode(i)}) 49 | 50 | 51 | def create_bulk_docs(db): 52 | """Create lots of docs, lots at a time""" 53 | batch_size = 100 54 | num_batches = 1000 55 | for i in range(num_batches): 56 | db.update([{'_id': unicode((i * batch_size) + j)} for j in range(batch_size)]) 57 | 58 | 59 | if __name__ == '__main__': 60 | main() 61 | -------------------------------------------------------------------------------- /doc/getting-started.rst: -------------------------------------------------------------------------------- 1 | Getting started with couchdb-python 2 | =================================== 3 | 4 | Some snippets of code to get you started with writing code against CouchDB. 5 | 6 | Starting off:: 7 | 8 | >>> import couchdb 9 | >>> couch = couchdb.Server() 10 | 11 | This gets you a Server object, representing a CouchDB server. By default, it 12 | assumes CouchDB is running on localhost:5984. If your CouchDB server is 13 | running elsewhere, set it up like this: 14 | 15 | >>> couch = couchdb.Server('http://example.com:5984/') 16 | 17 | You can also pass authentication credentials and/or use SSL: 18 | 19 | >>> couch = couchdb.Server('https://username:password@host:port/') 20 | 21 | You can create a new database from Python, or use an existing database: 22 | 23 | >>> db = couch.create('test') # newly created 24 | >>> db = couch['mydb'] # existing 25 | 26 | After selecting a database, create a document and insert it into the db: 27 | 28 | >>> doc = {'foo': 'bar'} 29 | >>> db.save(doc) 30 | ('e0658cab843b59e63c8779a9a5000b01', '1-4c6114c65e295552ab1019e2b046b10e') 31 | >>> doc 32 | {'_rev': '1-4c6114c65e295552ab1019e2b046b10e', 'foo': 'bar', '_id': 'e0658cab843b59e63c8779a9a5000b01'} 33 | 34 | The ``save()`` method returns the ID and "rev" for the newly created document. 35 | You can also set your own ID by including an ``_id`` item in the document. 36 | 37 | Getting the document out again is easy: 38 | 39 | >>> db['e0658cab843b59e63c8779a9a5000b01'] 40 | 41 | 42 | To find all your documents, simply iterate over the database: 43 | 44 | >>> for id in db: 45 | ... print id 46 | ... 47 | 'e0658cab843b59e63c8779a9a5000b01' 48 | 49 | Now we can clean up the test document and database we created: 50 | 51 | >>> db.delete(doc) 52 | >>> couch.delete('test') 53 | -------------------------------------------------------------------------------- /couchdb/tests/testutil.py: -------------------------------------------------------------------------------- 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 | import doctest 10 | import random 11 | import re 12 | import sys 13 | 14 | from couchdb import client 15 | 16 | class Py23DocChecker(doctest.OutputChecker): 17 | def check_output(self, want, got, optionflags): 18 | if sys.version_info[0] > 2: 19 | want = re.sub("u'(.*?)'", "'\\1'", want) 20 | want = re.sub('u"(.*?)"', '"\\1"', want) 21 | else: 22 | want = re.sub("b'(.*?)'", "'\\1'", want) 23 | want = re.sub('b"(.*?)"', '"\\1"', want) 24 | return doctest.OutputChecker.check_output(self, want, got, optionflags) 25 | 26 | def doctest_suite(mod): 27 | return doctest.DocTestSuite(mod, checker=Py23DocChecker()) 28 | 29 | class TempDatabaseMixin(object): 30 | 31 | temp_dbs = None 32 | _db = None 33 | 34 | def setUp(self): 35 | self.server = client.Server(full_commit=False) 36 | 37 | def tearDown(self): 38 | if self.temp_dbs: 39 | for name in self.temp_dbs: 40 | self.server.delete(name) 41 | 42 | def temp_db(self): 43 | if self.temp_dbs is None: 44 | self.temp_dbs = {} 45 | # Find an unused database name 46 | while True: 47 | name = 'couchdb-python/%d' % random.randint(0, sys.maxsize) 48 | if name not in self.temp_dbs: 49 | break 50 | db = self.server.create(name) 51 | self.temp_dbs[name] = db 52 | return name, db 53 | 54 | def del_db(self, name): 55 | del self.temp_dbs[name] 56 | self.server.delete(name) 57 | 58 | @property 59 | def db(self): 60 | if self._db is None: 61 | name, self._db = self.temp_db() 62 | return self._db 63 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | CouchDB-Python Library 2 | ====================== 3 | 4 | .. image:: https://travis-ci.org/djc/couchdb-python.svg 5 | :target: https://travis-ci.org/djc/couchdb-python 6 | 7 | **Note: CouchDB-Python is no longer being maintained. After 8 years of maintaining 8 | CouchDB-Python, I no longer have time to address open issues and new bug reports. 9 | Consider https://github.com/cloudant/python-cloudant as an alternative. 10 | If you're interested in taking over maintenance of CouchDB-Python, please start a 11 | discussion on the mailing list, or open an issue or PR.** 12 | 13 | A Python library for working with CouchDB. `Downloads`_ are available via `PyPI`_. 14 | Our `documentation`_ is also hosted there. We have a `mailing list`_. 15 | 16 | This package currently encompasses four primary modules: 17 | 18 | * ``couchdb.client``: the basic client library 19 | * ``couchdb.design``: management of design documents 20 | * ``couchdb.mapping``: a higher-level API for mapping between CouchDB documents and Python objects 21 | * ``couchdb.view``: a CouchDB view server that allows writing view functions in Python 22 | 23 | It also provides a couple of command-line tools: 24 | 25 | * ``couchdb-dump``: writes a snapshot of a CouchDB database (including documents, attachments, and design documents) to MIME multipart file 26 | * ``couchdb-load``: reads a MIME multipart file as generated by couchdb-dump and loads all the documents, attachments, and design documents into a CouchDB database 27 | * ``couchdb-replicate``: can be used as an update-notification script to trigger replication between databases when data is changed 28 | 29 | Prerequisites: 30 | 31 | * Python 2.7, 3.4 or later 32 | * CouchDB 0.10.x or later (0.9.x should probably work, as well) 33 | 34 | ``simplejson`` will be used if installed. 35 | 36 | .. _Downloads: http://pypi.python.org/pypi/CouchDB 37 | .. _PyPI: http://pypi.python.org/ 38 | .. _documentation: http://couchdb-python.readthedocs.io/en/latest/ 39 | .. _mailing list: http://groups.google.com/group/couchdb-python 40 | -------------------------------------------------------------------------------- /couchdb/tests/design.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2008 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 | import unittest 10 | 11 | from couchdb import design 12 | from couchdb.tests import testutil 13 | 14 | 15 | class DesignTestCase(testutil.TempDatabaseMixin, unittest.TestCase): 16 | 17 | def test_options(self): 18 | options = {'collation': 'raw'} 19 | view = design.ViewDefinition( 20 | 'foo', 'foo', 21 | 'function(doc) {emit(doc._id, doc._rev)}', 22 | options=options) 23 | _, db = self.temp_db() 24 | view.sync(db) 25 | design_doc = db.get('_design/foo') 26 | self.assertTrue(design_doc['views']['foo']['options'] == options) 27 | 28 | def test_retrieve_view_defn(self): 29 | '''see issue 183''' 30 | view_def = design.ViewDefinition('foo', 'bar', 'baz') 31 | result = view_def.sync(self.db) 32 | self.assertTrue(isinstance(result, list)) 33 | self.assertEqual(result[0][0], True) 34 | self.assertEqual(result[0][1], '_design/foo') 35 | doc = self.db[result[0][1]] 36 | self.assertEqual(result[0][2], doc['_rev']) 37 | 38 | def test_sync_many(self): 39 | '''see issue 218''' 40 | func = 'function(doc) { emit(doc._id, doc._rev); }' 41 | first_view = design.ViewDefinition('design_doc', 'view_one', func) 42 | second_view = design.ViewDefinition('design_doc_two', 'view_one', func) 43 | third_view = design.ViewDefinition('design_doc', 'view_two', func) 44 | _, db = self.temp_db() 45 | results = design.ViewDefinition.sync_many( 46 | db, (first_view, second_view, third_view)) 47 | self.assertEqual( 48 | len(results), 2, 'There should only be two design documents') 49 | 50 | 51 | def suite(): 52 | suite = unittest.TestSuite() 53 | suite.addTest(unittest.makeSuite(DesignTestCase)) 54 | suite.addTest(testutil.doctest_suite(design)) 55 | return suite 56 | 57 | 58 | if __name__ == '__main__': 59 | unittest.main(defaultTest='suite') 60 | -------------------------------------------------------------------------------- /couchdb/tests/loader.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2016 Daniel Holth 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 | import unittest 10 | import os.path 11 | 12 | from couchdb import loader 13 | from couchdb.tests import testutil 14 | 15 | expected = { 16 | '_id': u'_design/loader', 17 | 'filters': {'filter': u'function(doc, req) { return true; }'}, 18 | 'language': u'javascript', 19 | 'views': {'a': {'map': u'function(doc) {\n emit(doc.property_to_index);\n}'}}} 20 | 21 | class LoaderTestCase(unittest.TestCase): 22 | 23 | directory = os.path.join(os.path.dirname(__file__), '_loader') 24 | 25 | def test_loader(self): 26 | doc = loader.load_design_doc(self.directory, 27 | strip=True, 28 | predicate=lambda x: \ 29 | not x.endswith('.xml')) 30 | self.assertEqual(doc, expected) 31 | 32 | def test_bad_directory(self): 33 | def bad_directory(): 34 | doc = loader.load_design_doc('directory_does_not_exist') 35 | 36 | self.assertRaises(OSError, bad_directory) 37 | 38 | def test_clobber_1(self): 39 | def clobber(): 40 | doc = loader.load_design_doc(self.directory, 41 | strip=True, 42 | predicate=lambda x: \ 43 | not x.endswith('filters.xml')) 44 | 45 | self.assertRaises(loader.DuplicateKeyError, clobber) 46 | 47 | def test_clobber_2(self): 48 | def clobber(): 49 | doc = loader.load_design_doc(self.directory, 50 | strip=True, 51 | predicate=lambda x: \ 52 | not x.endswith('language.xml')) 53 | 54 | self.assertRaises(loader.DuplicateKeyError, clobber) 55 | 56 | 57 | def suite(): 58 | suite = unittest.TestSuite() 59 | suite.addTest(unittest.makeSuite(LoaderTestCase)) 60 | return suite 61 | 62 | 63 | if __name__ == '__main__': 64 | unittest.main(defaultTest='suite') 65 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright (C) 2007-2009 Christopher Lenz 5 | # All rights reserved. 6 | # 7 | # This software is licensed as described in the file COPYING, which 8 | # you should have received as part of this distribution. 9 | 10 | import sys 11 | try: 12 | from setuptools import setup 13 | has_setuptools = True 14 | except ImportError: 15 | from distutils.core import setup 16 | has_setuptools = False 17 | 18 | 19 | # Build setuptools-specific options (if installed). 20 | if not has_setuptools: 21 | print("WARNING: setuptools/distribute not available. Console scripts will not be installed.") 22 | setuptools_options = {} 23 | else: 24 | setuptools_options = { 25 | 'entry_points': { 26 | 'console_scripts': [ 27 | 'couchpy = couchdb.view:main', 28 | 'couchdb-dump = couchdb.tools.dump:main', 29 | 'couchdb-load = couchdb.tools.load:main', 30 | 'couchdb-replicate = couchdb.tools.replicate:main', 31 | 'couchdb-load-design-doc = couchdb.loader:main', 32 | ], 33 | }, 34 | 'install_requires': [], 35 | 'test_suite': 'couchdb.tests.__main__.suite', 36 | 'zip_safe': True, 37 | } 38 | 39 | 40 | setup( 41 | name = 'CouchDB', 42 | version = '1.2.1', 43 | description = 'Python library for working with CouchDB', 44 | long_description = \ 45 | """This is a Python library for CouchDB. It provides a convenient high level 46 | interface for the CouchDB server.""", 47 | author = 'Christopher Lenz', 48 | author_email = 'cmlenz@gmx.de', 49 | maintainer = 'Dirkjan Ochtman', 50 | maintainer_email = 'dirkjan@ochtman.nl', 51 | license = 'BSD', 52 | url = 'https://github.com/djc/couchdb-python/', 53 | classifiers = [ 54 | 'Development Status :: 6 - Mature', 55 | 'Intended Audience :: Developers', 56 | 'License :: OSI Approved :: BSD License', 57 | 'Operating System :: OS Independent', 58 | 'Programming Language :: Python :: 2', 59 | 'Programming Language :: Python :: 2.7', 60 | 'Programming Language :: Python :: 3', 61 | 'Programming Language :: Python :: 3.4', 62 | 'Programming Language :: Python :: 3.5', 63 | 'Topic :: Database :: Front-Ends', 64 | 'Topic :: Software Development :: Libraries :: Python Modules', 65 | ], 66 | packages = ['couchdb', 'couchdb.tools', 'couchdb.tests'], 67 | **setuptools_options 68 | ) 69 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | 15 | .PHONY: help clean html dirhtml pickle json htmlhelp qthelp latex changes linkcheck doctest 16 | 17 | all: html 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " pickle to make pickle files" 24 | @echo " json to make JSON files" 25 | @echo " htmlhelp to make HTML files and a HTML help project" 26 | @echo " qthelp to make HTML files and a qthelp project" 27 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 28 | @echo " changes to make an overview of all changed/added/deprecated items" 29 | @echo " linkcheck to check all external links for integrity" 30 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 31 | 32 | clean: 33 | -rm -rf $(BUILDDIR)/* 34 | 35 | html: 36 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 37 | @echo 38 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 39 | 40 | dirhtml: 41 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 42 | @echo 43 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 44 | 45 | pickle: 46 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 47 | @echo 48 | @echo "Build finished; now you can process the pickle files." 49 | 50 | json: 51 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 52 | @echo 53 | @echo "Build finished; now you can process the JSON files." 54 | 55 | htmlhelp: 56 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 57 | @echo 58 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 59 | ".hhp project file in $(BUILDDIR)/htmlhelp." 60 | 61 | qthelp: 62 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 63 | @echo 64 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 65 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 66 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/couchdb-python.qhcp" 67 | @echo "To view the help file:" 68 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/couchdb-python.qhc" 69 | 70 | latex: 71 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 72 | @echo 73 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 74 | @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ 75 | "run these through (pdf)latex." 76 | 77 | changes: 78 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 79 | @echo 80 | @echo "The overview file is in $(BUILDDIR)/changes." 81 | 82 | linkcheck: 83 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 84 | @echo 85 | @echo "Link check complete; look for any errors in the above output " \ 86 | "or in $(BUILDDIR)/linkcheck/output.txt." 87 | 88 | doctest: 89 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 90 | @echo "Testing of doctests in the sources finished, look at the " \ 91 | "results in $(BUILDDIR)/doctest/output.txt." 92 | -------------------------------------------------------------------------------- /couchdb/tools/load.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright (C) 2007-2009 Christopher Lenz 5 | # All rights reserved. 6 | # 7 | # This software is licensed as described in the file COPYING, which 8 | # you should have received as part of this distribution. 9 | 10 | """Utility for loading a snapshot of a CouchDB database from a multipart MIME 11 | file. 12 | """ 13 | 14 | from __future__ import print_function 15 | from base64 import b64encode 16 | from optparse import OptionParser 17 | import sys 18 | 19 | from couchdb import __version__ as VERSION 20 | from couchdb import json 21 | from couchdb.client import Database 22 | from couchdb.multipart import read_multipart 23 | 24 | 25 | def load_db(fileobj, dburl, username=None, password=None, ignore_errors=False): 26 | db = Database(dburl) 27 | if username is not None and password is not None: 28 | db.resource.credentials = (username, password) 29 | 30 | for headers, is_multipart, payload in read_multipart(fileobj): 31 | docid = headers['content-id'] 32 | 33 | if is_multipart: # doc has attachments 34 | for headers, _, payload in payload: 35 | if 'content-id' not in headers: 36 | doc = json.decode(payload) 37 | doc['_attachments'] = {} 38 | else: 39 | doc['_attachments'][headers['content-id']] = { 40 | 'data': b64encode(payload).decode('ascii'), 41 | 'content_type': headers['content-type'], 42 | 'length': len(payload) 43 | } 44 | 45 | else: # no attachments, just the JSON 46 | doc = json.decode(payload) 47 | 48 | del doc['_rev'] 49 | print('Loading document %r' % docid, file=sys.stderr) 50 | try: 51 | db[docid] = doc 52 | except Exception as e: 53 | if not ignore_errors: 54 | raise 55 | print('Error: %s' % e, file=sys.stderr) 56 | 57 | 58 | def main(): 59 | parser = OptionParser(usage='%prog [options] dburl', version=VERSION) 60 | parser.add_option('--input', action='store', dest='input', metavar='FILE', 61 | help='the name of the file to read from') 62 | parser.add_option('--ignore-errors', action='store_true', 63 | dest='ignore_errors', 64 | help='whether to ignore errors in document creation ' 65 | 'and continue with the remaining documents') 66 | parser.add_option('--json-module', action='store', dest='json_module', 67 | help='the JSON module to use ("simplejson", "cjson", ' 68 | 'or "json" are supported)') 69 | parser.add_option('-u', '--username', action='store', dest='username', 70 | help='the username to use for authentication') 71 | parser.add_option('-p', '--password', action='store', dest='password', 72 | help='the password to use for authentication') 73 | parser.set_defaults(input='-') 74 | options, args = parser.parse_args() 75 | 76 | if len(args) != 1: 77 | return parser.error('incorrect number of arguments') 78 | 79 | if options.input != '-': 80 | fileobj = open(options.input, 'rb') 81 | else: 82 | fileobj = sys.stdin 83 | 84 | if options.json_module: 85 | json.use(options.json_module) 86 | 87 | load_db(fileobj, args[0], username=options.username, 88 | password=options.password, ignore_errors=options.ignore_errors) 89 | 90 | 91 | if __name__ == '__main__': 92 | main() 93 | -------------------------------------------------------------------------------- /couchdb/tools/dump.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright (C) 2007-2009 Christopher Lenz 5 | # All rights reserved. 6 | # 7 | # This software is licensed as described in the file COPYING, which 8 | # you should have received as part of this distribution. 9 | 10 | """Utility for dumping a snapshot of a CouchDB database to a multipart MIME 11 | file. 12 | """ 13 | 14 | from __future__ import print_function 15 | from base64 import b64decode 16 | from optparse import OptionParser 17 | import sys 18 | 19 | from couchdb import __version__ as VERSION 20 | from couchdb import json 21 | from couchdb.client import Database 22 | from couchdb.multipart import write_multipart 23 | 24 | BULK_SIZE = 1000 25 | 26 | def dump_docs(envelope, db, docs): 27 | for doc in docs: 28 | 29 | print('Dumping document %r' % doc.id, file=sys.stderr) 30 | attachments = doc.pop('_attachments', {}) 31 | jsondoc = json.encode(doc) 32 | 33 | if attachments: 34 | parts = envelope.open({ 35 | 'Content-ID': doc.id, 36 | 'ETag': '"%s"' % doc.rev 37 | }) 38 | parts.add('application/json', jsondoc) 39 | for name, info in attachments.items(): 40 | 41 | content_type = info.get('content_type') 42 | if content_type is None: # CouchDB < 0.8 43 | content_type = info.get('content-type') 44 | 45 | if 'data' not in info: 46 | data = db.get_attachment(doc, name).read() 47 | else: 48 | data = b64decode(info['data']) 49 | 50 | parts.add(content_type, data, {'Content-ID': name}) 51 | 52 | parts.close() 53 | 54 | else: 55 | envelope.add('application/json', jsondoc, { 56 | 'Content-ID': doc.id, 57 | 'ETag': '"%s"' % doc.rev 58 | }) 59 | 60 | def dump_db(dburl, username=None, password=None, boundary=None, 61 | output=None, bulk_size=BULK_SIZE): 62 | 63 | if output is None: 64 | output = sys.stdout if sys.version_info[0] < 3 else sys.stdout.buffer 65 | 66 | db = Database(dburl) 67 | if username is not None and password is not None: 68 | db.resource.credentials = username, password 69 | 70 | envelope = write_multipart(output, boundary=boundary) 71 | start, num = 0, db.info()['doc_count'] 72 | while start < num: 73 | opts = {'limit': bulk_size, 'skip': start, 'include_docs': True} 74 | docs = (row.doc for row in db.view('_all_docs', **opts)) 75 | dump_docs(envelope, db, docs) 76 | start += bulk_size 77 | 78 | envelope.close() 79 | 80 | 81 | def main(): 82 | parser = OptionParser(usage='%prog [options] dburl', version=VERSION) 83 | parser.add_option('--json-module', action='store', dest='json_module', 84 | help='the JSON module to use ("simplejson", "cjson", ' 85 | 'or "json" are supported)') 86 | parser.add_option('-u', '--username', action='store', dest='username', 87 | help='the username to use for authentication') 88 | parser.add_option('-p', '--password', action='store', dest='password', 89 | help='the password to use for authentication') 90 | parser.add_option('-b', '--bulk-size', action='store', dest='bulk_size', 91 | type='int', default=BULK_SIZE, 92 | help='number of docs retrieved from database') 93 | parser.set_defaults() 94 | options, args = parser.parse_args() 95 | 96 | if len(args) != 1: 97 | return parser.error('incorrect number of arguments') 98 | 99 | if options.json_module: 100 | json.use(options.json_module) 101 | 102 | dump_db(args[0], username=options.username, password=options.password, 103 | bulk_size=options.bulk_size) 104 | 105 | 106 | if __name__ == '__main__': 107 | main() 108 | -------------------------------------------------------------------------------- /couchdb/tests/couchhttp.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 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 | import socket 10 | import time 11 | import unittest 12 | 13 | from couchdb import http, util 14 | from couchdb.tests import testutil 15 | 16 | 17 | class SessionTestCase(testutil.TempDatabaseMixin, unittest.TestCase): 18 | 19 | def test_timeout(self): 20 | dbname, db = self.temp_db() 21 | timeout = 1 22 | session = http.Session(timeout=timeout) 23 | start = time.time() 24 | status, headers, body = session.request('GET', db.resource.url + '/_changes?feed=longpoll&since=1000&timeout=%s' % (timeout*2*1000,)) 25 | self.assertRaises(socket.timeout, body.read) 26 | self.assertTrue(time.time() - start < timeout * 1.3) 27 | 28 | def test_timeout_retry(self): 29 | dbname, db = self.temp_db() 30 | timeout = 1e-12 31 | session = http.Session(timeout=timeout, retryable_errors=["timed out"]) 32 | self.assertRaises(socket.timeout, session.request, 'GET', db.resource.url) 33 | 34 | 35 | class ResponseBodyTestCase(unittest.TestCase): 36 | def test_close(self): 37 | class TestStream(util.StringIO): 38 | def isclosed(self): 39 | return len(self.getvalue()) == self.tell() 40 | 41 | class ConnPool(object): 42 | def __init__(self): 43 | self.value = 0 44 | def release(self, url, conn): 45 | self.value += 1 46 | 47 | conn_pool = ConnPool() 48 | stream = TestStream(b'foobar') 49 | stream.msg = {} 50 | response = http.ResponseBody(stream, conn_pool, 'a', 'b') 51 | 52 | response.read(10) # read more than stream has. close() is called 53 | response.read() # steam ended. another close() call 54 | 55 | self.assertEqual(conn_pool.value, 1) 56 | 57 | def test_double_iteration_over_same_response_body(self): 58 | class TestHttpResp(object): 59 | msg = {'transfer-encoding': 'chunked'} 60 | def __init__(self, fp): 61 | self.fp = fp 62 | def close(self): 63 | pass 64 | def isclosed(self): 65 | return len(self.fp.getvalue()) == self.fp.tell() 66 | 67 | data = b'foobarbaz\n' 68 | data = b'\n'.join([hex(len(data))[2:].encode('utf-8'), data]) 69 | response = http.ResponseBody(TestHttpResp(util.StringIO(data)), 70 | None, None, None) 71 | self.assertEqual(list(response.iterchunks()), [b'foobarbaz\n']) 72 | self.assertEqual(list(response.iterchunks()), []) 73 | 74 | 75 | class CacheTestCase(testutil.TempDatabaseMixin, unittest.TestCase): 76 | 77 | def test_remove_miss(self): 78 | """Check that a cache remove miss is handled gracefully.""" 79 | url = 'http://localhost:5984/foo' 80 | cache = http.Cache() 81 | cache.put(url, (None, None, None)) 82 | cache.remove(url) 83 | cache.remove(url) 84 | 85 | def test_cache_clean(self): 86 | cache = http.Cache() 87 | cache.put('foo', (None, {'Date': 'Sat, 14 Feb 2009 02:31:28 -0000'}, None)) 88 | cache.put('bar', (None, {'Date': 'Sat, 14 Feb 2009 02:31:29 -0000'}, None)) 89 | cache.put('baz', (None, {'Date': 'Sat, 14 Feb 2009 02:31:30 -0000'}, None)) 90 | cache.keep_size = 1 91 | cache._clean() 92 | self.assertEqual(len(cache.by_url), 1) 93 | self.assertTrue('baz' in cache.by_url) 94 | 95 | 96 | def suite(): 97 | suite = unittest.TestSuite() 98 | suite.addTest(testutil.doctest_suite(http)) 99 | suite.addTest(unittest.makeSuite(SessionTestCase, 'test')) 100 | suite.addTest(unittest.makeSuite(ResponseBodyTestCase, 'test')) 101 | suite.addTest(unittest.makeSuite(CacheTestCase, 'test')) 102 | return suite 103 | 104 | 105 | if __name__ == '__main__': 106 | unittest.main(defaultTest='suite') 107 | -------------------------------------------------------------------------------- /couchdb/tools/replicate.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright 2009 Maximillian Dornseif 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 | """ 10 | This script replicates databases from one CouchDB server to an other. 11 | 12 | This is mainly for backup purposes or "priming" a new server before 13 | setting up trigger based replication. But you can also use the 14 | '--continuous' option to set up automatic replication on newer 15 | CouchDB versions. 16 | 17 | Use 'python replicate.py --help' to get more detailed usage instructions. 18 | """ 19 | 20 | from couchdb import http, client, util 21 | import optparse 22 | import sys 23 | import time 24 | import fnmatch 25 | 26 | def findpath(parser, s): 27 | '''returns (server url, path component)''' 28 | 29 | if s == '.': 30 | return client.DEFAULT_BASE_URL, '' 31 | if not s.startswith('http'): 32 | return client.DEFAULT_BASE_URL, s 33 | 34 | bits = util.urlparse(s) 35 | res = http.Resource('%s://%s/' % (bits.scheme, bits.netloc), None) 36 | parts = bits.path.split('/')[1:] 37 | if parts and not parts[-1]: 38 | parts = parts[:-1] 39 | 40 | cut = None 41 | for i in range(0, len(parts) + 1): 42 | try: 43 | data = res.get_json('/'.join(parts[:i]))[2] 44 | except Exception: 45 | data = None 46 | if data and 'couchdb' in data: 47 | cut = i 48 | 49 | if cut is None: 50 | raise parser.error("'%s' does not appear to be a CouchDB" % s) 51 | 52 | base = res.url + (parts[:cut] and '/'.join(parts[:cut]) or '') 53 | return base, '/'.join(parts[cut:]) 54 | 55 | def main(): 56 | 57 | usage = '%prog [options] ' 58 | parser = optparse.OptionParser(usage=usage) 59 | parser.add_option('--continuous', 60 | action='store_true', 61 | dest='continuous', 62 | help='trigger continuous replication in cochdb') 63 | parser.add_option('--compact', 64 | action='store_true', 65 | dest='compact', 66 | help='compact target database after replication') 67 | 68 | options, args = parser.parse_args() 69 | if len(args) != 2: 70 | raise parser.error('need source and target arguments') 71 | 72 | # set up server objects 73 | 74 | src, tgt = args 75 | sbase, spath = findpath(parser, src) 76 | source = client.Server(sbase) 77 | tbase, tpath = findpath(parser, tgt) 78 | target = client.Server(tbase) 79 | 80 | # check database name specs 81 | 82 | if '*' in tpath: 83 | raise parser.error('invalid target path: must be single db or empty') 84 | 85 | all = sorted(i for i in source if i[0] != '_') # Skip reserved names. 86 | if not spath: 87 | raise parser.error('source database must be specified') 88 | 89 | sources = [i for i in all if fnmatch.fnmatchcase(i, spath)] 90 | if not sources: 91 | raise parser.error("no source databases match glob '%s'" % spath) 92 | 93 | if len(sources) > 1 and tpath: 94 | raise parser.error('target path must be empty with multiple sources') 95 | elif len(sources) == 1: 96 | databases = [(sources[0], tpath)] 97 | else: 98 | databases = [(i, i) for i in sources] 99 | 100 | # do the actual replication 101 | 102 | for sdb, tdb in databases: 103 | 104 | start = time.time() 105 | print(sdb, '->', tdb) 106 | sys.stdout.flush() 107 | 108 | if tdb not in target: 109 | target.create(tdb) 110 | sys.stdout.write("created") 111 | sys.stdout.flush() 112 | 113 | sdb = '%s%s' % (sbase, util.urlquote(sdb, '')) 114 | if options.continuous: 115 | target.replicate(sdb, tdb, continuous=options.continuous) 116 | else: 117 | target.replicate(sdb, tdb) 118 | print('%.1fs' % (time.time() - start)) 119 | sys.stdout.flush() 120 | 121 | if options.compact: 122 | for (sdb, tdb) in databases: 123 | print('compact', tdb) 124 | target[tdb].compact() 125 | 126 | if __name__ == '__main__': 127 | main() 128 | -------------------------------------------------------------------------------- /couchdb/loader.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Load design documents from the filesystem into a dict. 5 | Subset of couchdbkit/couchapp functionality. 6 | 7 | Description 8 | ----------- 9 | 10 | Convert a target directory into an object (dict). 11 | 12 | Each filename (without extension) or subdirectory name is a key in this object. 13 | 14 | For files, the utf-8-decoded contents are the value, except for .json files 15 | which are first decoded as json. 16 | 17 | Subdirectories are converted into objects using the same procedure and then 18 | added to the parent object. 19 | 20 | Typically used for design documents. This directory tree:: 21 | 22 | . 23 | ├── filters 24 | │   └── forms_only.js 25 | ├── _id 26 | ├── language 27 | ├── lib 28 | │   └── validate.js 29 | └── views 30 | ├── view_a 31 | │   └── map.js 32 | ├── view_b 33 | │   └── map.js 34 | └── view_c 35 | └── map.js 36 | 37 | Becomes this object:: 38 | 39 | { 40 | "views": { 41 | "view_a": { 42 | "map": "function(doc) { ... }" 43 | }, 44 | "view_b": { 45 | "map": "function(doc) { ... }" 46 | }, 47 | "view_c": { 48 | "map": "function(doc) { ... }" 49 | } 50 | }, 51 | "_id": "_design/name_of_design_document", 52 | "filters": { 53 | "forms_only": "function(doc, req) { ... }" 54 | }, 55 | "language": "javascript", 56 | "lib": { 57 | "validate": "// A library for validations ..." 58 | } 59 | } 60 | 61 | """ 62 | 63 | from __future__ import unicode_literals, absolute_import 64 | 65 | import os.path 66 | import pprint 67 | import codecs 68 | import json 69 | 70 | class DuplicateKeyError(ValueError): 71 | pass 72 | 73 | def load_design_doc(directory, strip=False, predicate=lambda x: True): 74 | """ 75 | Load a design document from the filesystem. 76 | 77 | strip: remove leading and trailing whitespace from file contents, 78 | like couchdbkit. 79 | 80 | predicate: function that is passed the full path to each file or directory. 81 | Each entry is only added to the document if predicate returns True. 82 | Can be used to ignore backup files etc. 83 | """ 84 | objects = {} 85 | 86 | if not os.path.isdir(directory): 87 | raise OSError("No directory: '{0}'".format(directory)) 88 | 89 | for (dirpath, dirnames, filenames) in os.walk(directory, topdown=False): 90 | key = os.path.split(dirpath)[-1] 91 | ob = {} 92 | objects[dirpath] = (key, ob) 93 | 94 | for name in filenames: 95 | fkey = os.path.splitext(name)[0] 96 | fullname = os.path.join(dirpath, name) 97 | if not predicate(fullname): continue 98 | if fkey in ob: 99 | raise DuplicateKeyError("file '{0}' clobbers key '{1}'" 100 | .format(fullname, fkey)) 101 | with codecs.open(fullname, 'r', 'utf-8') as f: 102 | contents = f.read() 103 | if name.endswith('.json'): 104 | contents = json.loads(contents) 105 | elif strip: 106 | contents = contents.strip() 107 | ob[fkey] = contents 108 | 109 | for name in dirnames: 110 | if name == '_attachments': 111 | raise NotImplementedError("_attachments are not supported") 112 | fullpath = os.path.join(dirpath, name) 113 | if not predicate(fullpath): continue 114 | subkey, subthing = objects[fullpath] 115 | if subkey in ob: 116 | raise DuplicateKeyError("directory '{0}' clobbers key '{1}'" 117 | .format(fullpath,subkey)) 118 | ob[subkey] = subthing 119 | 120 | return ob 121 | 122 | 123 | def main(): 124 | import sys 125 | try: 126 | directory = sys.argv[1] 127 | except IndexError: 128 | sys.stderr.write("Usage:\n\t{0} [directory]\n".format(sys.argv[0])) 129 | sys.exit(1) 130 | obj = load_design_doc(directory) 131 | sys.stdout.write(json.dumps(obj, indent=2)) 132 | 133 | 134 | if __name__ == "__main__": 135 | main() 136 | -------------------------------------------------------------------------------- /couchdb/tests/view.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2007-2008 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 | import unittest 10 | 11 | from couchdb.util import StringIO 12 | from couchdb import view 13 | from couchdb.tests import testutil 14 | 15 | 16 | class ViewServerTestCase(unittest.TestCase): 17 | 18 | def test_reset(self): 19 | input = StringIO(b'["reset"]\n') 20 | output = StringIO() 21 | view.run(input=input, output=output) 22 | self.assertEqual(output.getvalue(), b'true\n') 23 | 24 | def test_add_fun(self): 25 | input = StringIO(b'["add_fun", "def fun(doc): yield None, doc"]\n') 26 | output = StringIO() 27 | view.run(input=input, output=output) 28 | self.assertEqual(output.getvalue(), b'true\n') 29 | 30 | def test_map_doc(self): 31 | input = StringIO(b'["add_fun", "def fun(doc): yield None, doc"]\n' 32 | b'["map_doc", {"foo": "bar"}]\n') 33 | output = StringIO() 34 | view.run(input=input, output=output) 35 | self.assertEqual(output.getvalue(), 36 | b'true\n' 37 | b'[[[null, {"foo": "bar"}]]]\n') 38 | 39 | def test_i18n(self): 40 | input = StringIO(b'["add_fun", "def fun(doc): yield doc[\\"test\\"], doc"]\n' 41 | b'["map_doc", {"test": "b\xc3\xa5r"}]\n') 42 | output = StringIO() 43 | view.run(input=input, output=output) 44 | self.assertEqual(output.getvalue(), 45 | b'true\n' 46 | b'[[["b\xc3\xa5r", {"test": "b\xc3\xa5r"}]]]\n') 47 | 48 | def test_map_doc_with_logging(self): 49 | fun = b'def fun(doc): log(\'running\'); yield None, doc' 50 | input = StringIO(b'["add_fun", "' + fun + b'"]\n' 51 | b'["map_doc", {"foo": "bar"}]\n') 52 | output = StringIO() 53 | view.run(input=input, output=output) 54 | self.assertEqual(output.getvalue(), 55 | b'true\n' 56 | b'["log", "running"]\n' 57 | b'[[[null, {"foo": "bar"}]]]\n') 58 | 59 | def test_map_doc_with_logging_json(self): 60 | fun = b'def fun(doc): log([1, 2, 3]); yield None, doc' 61 | input = StringIO(b'["add_fun", "' + fun + b'"]\n' 62 | b'["map_doc", {"foo": "bar"}]\n') 63 | output = StringIO() 64 | view.run(input=input, output=output) 65 | self.assertEqual(output.getvalue(), 66 | b'true\n' 67 | b'["log", "[1, 2, 3]"]\n' 68 | b'[[[null, {"foo": "bar"}]]]\n') 69 | 70 | def test_reduce(self): 71 | input = StringIO(b'["reduce", ' 72 | b'["def fun(keys, values): return sum(values)"], ' 73 | b'[[null, 1], [null, 2], [null, 3]]]\n') 74 | output = StringIO() 75 | view.run(input=input, output=output) 76 | self.assertEqual(output.getvalue(), b'[true, [6]]\n') 77 | 78 | def test_reduce_with_logging(self): 79 | input = StringIO(b'["reduce", ' 80 | b'["def fun(keys, values): log(\'Summing %r\' % (values,)); return sum(values)"], ' 81 | b'[[null, 1], [null, 2], [null, 3]]]\n') 82 | output = StringIO() 83 | view.run(input=input, output=output) 84 | self.assertEqual(output.getvalue(), 85 | b'["log", "Summing (1, 2, 3)"]\n' 86 | b'[true, [6]]\n') 87 | 88 | def test_rereduce(self): 89 | input = StringIO(b'["rereduce", ' 90 | b'["def fun(keys, values, rereduce): return sum(values)"], ' 91 | b'[1, 2, 3]]\n') 92 | output = StringIO() 93 | view.run(input=input, output=output) 94 | self.assertEqual(output.getvalue(), b'[true, [6]]\n') 95 | 96 | def test_reduce_empty(self): 97 | input = StringIO(b'["reduce", ' 98 | b'["def fun(keys, values): return sum(values)"], ' 99 | b'[]]\n') 100 | output = StringIO() 101 | view.run(input=input, output=output) 102 | self.assertEqual(output.getvalue(), 103 | b'[true, [0]]\n') 104 | 105 | 106 | def suite(): 107 | suite = unittest.TestSuite() 108 | suite.addTest(testutil.doctest_suite(view)) 109 | suite.addTest(unittest.makeSuite(ViewServerTestCase, 'test')) 110 | return suite 111 | 112 | 113 | if __name__ == '__main__': 114 | unittest.main(defaultTest='suite') 115 | -------------------------------------------------------------------------------- /couchdb/json.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 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 | """Thin abstraction layer over the different available modules for decoding 10 | and encoding JSON data. 11 | 12 | This module currently supports the following JSON modules: 13 | - ``simplejson``: https://github.com/simplejson/simplejson 14 | - ``cjson``: http://pypi.python.org/pypi/python-cjson 15 | - ``json``: This is the version of ``simplejson`` that is bundled with the 16 | Python standard library since version 2.6 17 | (see http://docs.python.org/library/json.html) 18 | 19 | The default behavior is to use ``simplejson`` if installed, and otherwise 20 | fallback to the standard library module. To explicitly tell CouchDB-Python 21 | which module to use, invoke the `use()` function with the module name:: 22 | 23 | from couchdb import json 24 | json.use('cjson') 25 | 26 | In addition to choosing one of the above modules, you can also configure 27 | CouchDB-Python to use custom decoding and encoding functions:: 28 | 29 | from couchdb import json 30 | json.use(decode=my_decode, encode=my_encode) 31 | 32 | """ 33 | 34 | __all__ = ['decode', 'encode', 'use'] 35 | 36 | from couchdb import util 37 | import warnings 38 | import os 39 | 40 | _initialized = False 41 | _using = os.environ.get('COUCHDB_PYTHON_JSON') 42 | _decode = None 43 | _encode = None 44 | 45 | 46 | def decode(string): 47 | """Decode the given JSON string. 48 | 49 | :param string: the JSON string to decode 50 | :type string: basestring 51 | :return: the corresponding Python data structure 52 | :rtype: object 53 | """ 54 | if not _initialized: 55 | _initialize() 56 | return _decode(string) 57 | 58 | 59 | def encode(obj): 60 | """Encode the given object as a JSON string. 61 | 62 | :param obj: the Python data structure to encode 63 | :type obj: object 64 | :return: the corresponding JSON string 65 | :rtype: basestring 66 | """ 67 | if not _initialized: 68 | _initialize() 69 | return _encode(obj) 70 | 71 | 72 | def use(module=None, decode=None, encode=None): 73 | """Set the JSON library that should be used, either by specifying a known 74 | module name, or by providing a decode and encode function. 75 | 76 | The modules "simplejson" and "json" are currently supported for the 77 | ``module`` parameter. 78 | 79 | If provided, the ``decode`` parameter must be a callable that accepts a 80 | JSON string and returns a corresponding Python data structure. The 81 | ``encode`` callable must accept a Python data structure and return the 82 | corresponding JSON string. Exceptions raised by decoding and encoding 83 | should be propagated up unaltered. 84 | 85 | :param module: the name of the JSON library module to use, or the module 86 | object itself 87 | :type module: str or module 88 | :param decode: a function for decoding JSON strings 89 | :type decode: callable 90 | :param encode: a function for encoding objects as JSON strings 91 | :type encode: callable 92 | """ 93 | global _decode, _encode, _initialized, _using 94 | if module is not None: 95 | if not isinstance(module, util.strbase): 96 | module = module.__name__ 97 | if module not in ('cjson', 'json', 'simplejson'): 98 | raise ValueError('Unsupported JSON module %s' % module) 99 | _using = module 100 | _initialized = False 101 | else: 102 | assert decode is not None and encode is not None 103 | _using = 'custom' 104 | _decode = decode 105 | _encode = encode 106 | _initialized = True 107 | 108 | 109 | def _initialize(): 110 | global _initialized 111 | 112 | def _init_simplejson(): 113 | global _decode, _encode 114 | import simplejson 115 | _decode = lambda string, loads=simplejson.loads: loads(string) 116 | _encode = lambda obj, dumps=simplejson.dumps: \ 117 | dumps(obj, allow_nan=False, ensure_ascii=False) 118 | 119 | def _init_cjson(): 120 | global _decode, _encode 121 | import cjson 122 | _decode = lambda string, decode=cjson.decode: decode(string) 123 | _encode = lambda obj, encode=cjson.encode: encode(obj) 124 | 125 | def _init_stdlib(): 126 | global _decode, _encode 127 | json = __import__('json', {}, {}) 128 | 129 | def _decode(string_, loads=json.loads): 130 | if isinstance(string_, util.btype): 131 | string_ = string_.decode("utf-8") 132 | return loads(string_) 133 | 134 | _encode = lambda obj, dumps=json.dumps: \ 135 | dumps(obj, allow_nan=False, ensure_ascii=False) 136 | 137 | if _using == 'simplejson': 138 | _init_simplejson() 139 | elif _using == 'cjson': 140 | warnings.warn("Builtin cjson support is deprecated. Please use the " 141 | "default or provide custom decode/encode functions " 142 | "[2011-11-09].", 143 | DeprecationWarning, stacklevel=1) 144 | _init_cjson() 145 | elif _using == 'json': 146 | _init_stdlib() 147 | elif _using != 'custom': 148 | try: 149 | _init_simplejson() 150 | except ImportError: 151 | _init_stdlib() 152 | _initialized = True 153 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # couchdb-python documentation build configuration file, created by 4 | # sphinx-quickstart on Thu Apr 29 18:32:43 2010. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | dir = os.path.dirname(__file__) 16 | sys.path.insert(0, os.path.abspath(os.path.join(dir, '..'))) 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | #sys.path.append(os.path.abspath('.')) 22 | 23 | # -- General configuration ----------------------------------------------------- 24 | 25 | # Add any Sphinx extension module names here, as strings. They can be extensions 26 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 27 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.doctest'] 28 | 29 | # Add any paths that contain templates here, relative to this directory. 30 | templates_path = ['templates'] 31 | 32 | # The suffix of source filenames. 33 | source_suffix = '.rst' 34 | 35 | # The encoding of source files. 36 | #source_encoding = 'utf-8' 37 | 38 | # The master toctree document. 39 | master_doc = 'index' 40 | 41 | # General information about the project. 42 | project = 'couchdb-python' 43 | copyright = '2010, Dirkjan Ochtman' 44 | 45 | # The version info for the project you're documenting, acts as replacement for 46 | # |version| and |release|, also used in various other places throughout the 47 | # built documents. 48 | # 49 | # The short X.Y version. 50 | version = '1.0' 51 | # The full version, including alpha/beta/rc tags. 52 | release = '1.0' 53 | 54 | # The language for content autogenerated by Sphinx. Refer to documentation 55 | # for a list of supported languages. 56 | #language = None 57 | 58 | # There are two options for replacing |today|: either, you set today to some 59 | # non-false value, then it is used: 60 | #today = '' 61 | # Else, today_fmt is used as the format for a strftime call. 62 | #today_fmt = '%B %d, %Y' 63 | 64 | # List of documents that shouldn't be included in the build. 65 | #unused_docs = [] 66 | 67 | # List of directories, relative to source directory, that shouldn't be searched 68 | # for source files. 69 | exclude_trees = ['build'] 70 | 71 | # The reST default role (used for this markup: `text`) to use for all documents. 72 | #default_role = None 73 | 74 | # If true, '()' will be appended to :func: etc. cross-reference text. 75 | #add_function_parentheses = True 76 | 77 | # If true, the current module name will be prepended to all description 78 | # unit titles (such as .. function::). 79 | #add_module_names = True 80 | 81 | # If true, sectionauthor and moduleauthor directives will be shown in the 82 | # output. They are ignored by default. 83 | #show_authors = False 84 | 85 | # The name of the Pygments (syntax highlighting) style to use. 86 | pygments_style = 'sphinx' 87 | 88 | # A list of ignored prefixes for module index sorting. 89 | #modindex_common_prefix = [] 90 | 91 | 92 | # -- Options for HTML output --------------------------------------------------- 93 | 94 | # The theme to use for HTML and HTML Help pages. Major themes that come with 95 | # Sphinx are currently 'default' and 'sphinxdoc'. 96 | html_theme = 'alabaster' 97 | 98 | # Theme options are theme-specific and customize the look and feel of a theme 99 | # further. For a list of options available for each theme, see the 100 | # documentation. 101 | #html_theme_options = {} 102 | 103 | # Add any paths that contain custom themes here, relative to this directory. 104 | #html_theme_path = [] 105 | 106 | # The name for this set of Sphinx documents. If None, it defaults to 107 | # " v documentation". 108 | #html_title = None 109 | 110 | # A shorter title for the navigation bar. Default is the same as html_title. 111 | #html_short_title = None 112 | 113 | # The name of an image file (relative to this directory) to place at the top 114 | # of the sidebar. 115 | #html_logo = None 116 | 117 | # The name of an image file (within the static path) to use as favicon of the 118 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 119 | # pixels large. 120 | #html_favicon = None 121 | 122 | # Add any paths that contain custom static files (such as style sheets) here, 123 | # relative to this directory. They are copied after the builtin static files, 124 | # so a file named "default.css" will overwrite the builtin "default.css". 125 | html_static_path = ['static'] 126 | 127 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 128 | # using the given strftime format. 129 | #html_last_updated_fmt = '%b %d, %Y' 130 | 131 | # If true, SmartyPants will be used to convert quotes and dashes to 132 | # typographically correct entities. 133 | #html_use_smartypants = True 134 | 135 | # Custom sidebar templates, maps document names to template names. 136 | #html_sidebars = {} 137 | 138 | # Additional templates that should be rendered to pages, maps page names to 139 | # template names. 140 | #html_additional_pages = {} 141 | 142 | # If false, no module index is generated. 143 | #html_use_modindex = True 144 | 145 | # If false, no index is generated. 146 | #html_use_index = True 147 | 148 | # If true, the index is split into individual pages for each letter. 149 | #html_split_index = False 150 | 151 | # If true, links to the reST sources are added to the pages. 152 | #html_show_sourcelink = True 153 | 154 | # If true, an OpenSearch description file will be output, and all pages will 155 | # contain a tag referring to it. The value of this option must be the 156 | # base URL from which the finished HTML is served. 157 | #html_use_opensearch = '' 158 | 159 | # If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). 160 | #html_file_suffix = '' 161 | 162 | # Output file base name for HTML help builder. 163 | htmlhelp_basename = 'couchdb-pythondoc' 164 | 165 | 166 | # -- Options for LaTeX output -------------------------------------------------- 167 | 168 | # The paper size ('letter' or 'a4'). 169 | #latex_paper_size = 'letter' 170 | 171 | # The font size ('10pt', '11pt' or '12pt'). 172 | #latex_font_size = '10pt' 173 | 174 | # Grouping the document tree into LaTeX files. List of tuples 175 | # (source start file, target name, title, author, documentclass [howto/manual]). 176 | latex_documents = [ 177 | ('index', 'couchdb-python.tex', 'couchdb-python Documentation', 178 | 'Dirkjan Ochtman', 'manual'), 179 | ] 180 | 181 | # The name of an image file (relative to this directory) to place at the top of 182 | # the title page. 183 | #latex_logo = None 184 | 185 | # For "manual" documents, if this is true, then toplevel headings are parts, 186 | # not chapters. 187 | #latex_use_parts = False 188 | 189 | # Additional stuff for the LaTeX preamble. 190 | #latex_preamble = '' 191 | 192 | # Documents to append as an appendix to all manuals. 193 | #latex_appendices = [] 194 | 195 | # If false, no module index is generated. 196 | #latex_use_modindex = True 197 | -------------------------------------------------------------------------------- /couchdb/view.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright (C) 2007-2008 Christopher Lenz 5 | # All rights reserved. 6 | # 7 | # This software is licensed as described in the file COPYING, which 8 | # you should have received as part of this distribution. 9 | 10 | """Implementation of a view server for functions written in Python.""" 11 | 12 | from codecs import BOM_UTF8 13 | import logging 14 | import os 15 | import sys 16 | import traceback 17 | from types import FunctionType 18 | 19 | from couchdb import json, util 20 | 21 | __all__ = ['main', 'run'] 22 | __docformat__ = 'restructuredtext en' 23 | 24 | log = logging.getLogger('couchdb.view') 25 | 26 | 27 | def run(input=sys.stdin, output=None): 28 | r"""CouchDB view function handler implementation for Python. 29 | 30 | :param input: the readable file-like object to read input from 31 | :param output: the writable file-like object to write output to 32 | """ 33 | functions = [] 34 | if output is None: 35 | output = sys.stdout if sys.version_info[0] < 3 else sys.stdout.buffer 36 | 37 | def _writejson(obj): 38 | obj = json.encode(obj) 39 | if isinstance(obj, util.utype): 40 | obj = obj.encode('utf-8') 41 | output.write(obj) 42 | output.write(b'\n') 43 | output.flush() 44 | 45 | def _log(message): 46 | if not isinstance(message, util.strbase): 47 | message = json.encode(message) 48 | _writejson(['log', message]) 49 | 50 | def reset(config=None): 51 | del functions[:] 52 | return True 53 | 54 | def add_fun(string): 55 | string = BOM_UTF8 + string.encode('utf-8') 56 | globals_ = {} 57 | try: 58 | util.pyexec(string, {'log': _log}, globals_) 59 | except Exception as e: 60 | return ['error', 61 | 'map_compilation_error', 62 | e.args[0] 63 | ] 64 | err = ['error', 65 | 'map_compilation_error', 66 | 'string must eval to a function ' 67 | '(ex: "def(doc): return 1")' 68 | ] 69 | if len(globals_) != 1: 70 | return err 71 | function = list(globals_.values())[0] 72 | if type(function) is not FunctionType: 73 | return err 74 | functions.append(function) 75 | return True 76 | 77 | def map_doc(doc): 78 | results = [] 79 | for function in functions: 80 | try: 81 | results.append([[key, value] for key, value in function(doc)]) 82 | except Exception as e: 83 | log.error('runtime error in map function: %s', e, 84 | exc_info=True) 85 | results.append([]) 86 | _log(traceback.format_exc()) 87 | return results 88 | 89 | def reduce(*cmd, **kwargs): 90 | code = BOM_UTF8 + cmd[0][0].encode('utf-8') 91 | args = cmd[1] 92 | globals_ = {} 93 | try: 94 | util.pyexec(code, {'log': _log}, globals_) 95 | except Exception as e: 96 | log.error('runtime error in reduce function: %s', e, 97 | exc_info=True) 98 | return ['error', 99 | 'reduce_compilation_error', 100 | e.args[0] 101 | ] 102 | err = ['error', 103 | 'reduce_compilation_error', 104 | 'string must eval to a function ' 105 | '(ex: "def(keys, values): return 1")' 106 | ] 107 | if len(globals_) != 1: 108 | return err 109 | function = list(globals_.values())[0] 110 | if type(function) is not FunctionType: 111 | return err 112 | 113 | rereduce = kwargs.get('rereduce', False) 114 | results = [] 115 | if rereduce: 116 | keys = None 117 | vals = args 118 | else: 119 | if args: 120 | keys, vals = zip(*args) 121 | else: 122 | keys, vals = [], [] 123 | if util.funcode(function).co_argcount == 3: 124 | results = function(keys, vals, rereduce) 125 | else: 126 | results = function(keys, vals) 127 | return [True, [results]] 128 | 129 | def rereduce(*cmd): 130 | # Note: weird kwargs is for Python 2.5 compat 131 | return reduce(*cmd, **{'rereduce': True}) 132 | 133 | handlers = {'reset': reset, 'add_fun': add_fun, 'map_doc': map_doc, 134 | 'reduce': reduce, 'rereduce': rereduce} 135 | 136 | try: 137 | while True: 138 | line = input.readline() 139 | if not line: 140 | break 141 | try: 142 | cmd = json.decode(line) 143 | log.debug('Processing %r', cmd) 144 | except ValueError as e: 145 | log.error('Error: %s', e, exc_info=True) 146 | return 1 147 | else: 148 | retval = handlers[cmd[0]](*cmd[1:]) 149 | log.debug('Returning %r', retval) 150 | _writejson(retval) 151 | except KeyboardInterrupt: 152 | return 0 153 | except Exception as e: 154 | log.error('Error: %s', e, exc_info=True) 155 | return 1 156 | 157 | 158 | _VERSION = """%(name)s - CouchDB Python %(version)s 159 | 160 | Copyright (C) 2007 Christopher Lenz . 161 | """ 162 | 163 | _HELP = """Usage: %(name)s [OPTION] 164 | 165 | The %(name)s command runs the CouchDB Python view server. 166 | 167 | The exit status is 0 for success or 1 for failure. 168 | 169 | Options: 170 | 171 | --version display version information and exit 172 | -h, --help display a short help message and exit 173 | --json-module= set the JSON module to use ('simplejson', 'cjson', 174 | or 'json' are supported) 175 | --log-file= name of the file to write log messages to, or '-' to 176 | enable logging to the standard error stream 177 | --debug enable debug logging; requires --log-file to be 178 | specified 179 | 180 | Report bugs via the web at . 181 | """ 182 | 183 | 184 | def main(): 185 | """Command-line entry point for running the view server.""" 186 | import getopt 187 | from couchdb import __version__ as VERSION 188 | 189 | try: 190 | option_list, argument_list = getopt.gnu_getopt( 191 | sys.argv[1:], 'h', 192 | ['version', 'help', 'json-module=', 'debug', 'log-file='] 193 | ) 194 | 195 | message = None 196 | for option, value in option_list: 197 | if option in ('--version'): 198 | message = _VERSION % dict(name=os.path.basename(sys.argv[0]), 199 | version=VERSION) 200 | elif option in ('-h', '--help'): 201 | message = _HELP % dict(name=os.path.basename(sys.argv[0])) 202 | elif option in ('--json-module'): 203 | json.use(module=value) 204 | elif option in ('--debug'): 205 | log.setLevel(logging.DEBUG) 206 | elif option in ('--log-file'): 207 | if value == '-': 208 | handler = logging.StreamHandler(sys.stderr) 209 | handler.setFormatter(logging.Formatter( 210 | ' -> [%(levelname)s] %(message)s' 211 | )) 212 | else: 213 | handler = logging.FileHandler(value) 214 | handler.setFormatter(logging.Formatter( 215 | '[%(asctime)s] [%(levelname)s] %(message)s' 216 | )) 217 | log.addHandler(handler) 218 | if message: 219 | sys.stdout.write(message) 220 | sys.stdout.flush() 221 | sys.exit(0) 222 | 223 | except getopt.GetoptError as error: 224 | message = '%s\n\nTry `%s --help` for more information.\n' % ( 225 | str(error), os.path.basename(sys.argv[0]) 226 | ) 227 | sys.stderr.write(message) 228 | sys.stderr.flush() 229 | sys.exit(1) 230 | 231 | sys.exit(run()) 232 | 233 | 234 | if __name__ == '__main__': 235 | main() 236 | -------------------------------------------------------------------------------- /couchdb/design.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2008-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 | """Utility code for managing design documents.""" 10 | 11 | from copy import deepcopy 12 | from inspect import getsource 13 | from itertools import groupby 14 | from operator import attrgetter 15 | from textwrap import dedent 16 | from types import FunctionType 17 | 18 | __all__ = ['ViewDefinition'] 19 | __docformat__ = 'restructuredtext en' 20 | 21 | 22 | class ViewDefinition(object): 23 | r"""Definition of a view stored in a specific design document. 24 | 25 | An instance of this class can be used to access the results of the view, 26 | as well as to keep the view definition in the design document up to date 27 | with the definition in the application code. 28 | 29 | >>> from couchdb import Server 30 | >>> server = Server() 31 | >>> db = server.create('python-tests') 32 | 33 | >>> view = ViewDefinition('tests', 'all', '''function(doc) { 34 | ... emit(doc._id, null); 35 | ... }''') 36 | >>> view.get_doc(db) 37 | 38 | The view is not yet stored in the database, in fact, design doc doesn't 39 | even exist yet. That can be fixed using the `sync` method: 40 | 41 | >>> view.sync(db) #doctest: +ELLIPSIS 42 | [(True, u'_design/tests', ...)] 43 | >>> design_doc = view.get_doc(db) 44 | >>> design_doc #doctest: +ELLIPSIS 45 | 46 | >>> print(design_doc['views']['all']['map']) 47 | function(doc) { 48 | emit(doc._id, null); 49 | } 50 | 51 | If you use a Python view server, you can also use Python functions instead 52 | of code embedded in strings: 53 | 54 | >>> def my_map(doc): 55 | ... yield doc['somekey'], doc['somevalue'] 56 | >>> view = ViewDefinition('test2', 'somename', my_map, language='python') 57 | >>> view.sync(db) #doctest: +ELLIPSIS 58 | [(True, u'_design/test2', ...)] 59 | >>> design_doc = view.get_doc(db) 60 | >>> design_doc #doctest: +ELLIPSIS 61 | 62 | >>> print(design_doc['views']['somename']['map']) 63 | def my_map(doc): 64 | yield doc['somekey'], doc['somevalue'] 65 | 66 | Use the static `sync_many()` method to create or update a collection of 67 | views in the database in an atomic and efficient manner, even across 68 | different design documents. 69 | 70 | >>> del server['python-tests'] 71 | """ 72 | 73 | def __init__(self, design, name, map_fun, reduce_fun=None, 74 | language='javascript', wrapper=None, options=None, 75 | **defaults): 76 | """Initialize the view definition. 77 | 78 | Note that the code in `map_fun` and `reduce_fun` is automatically 79 | dedented, that is, any common leading whitespace is removed from each 80 | line. 81 | 82 | :param design: the name of the design document 83 | :param name: the name of the view 84 | :param map_fun: the map function code 85 | :param reduce_fun: the reduce function code (optional) 86 | :param language: the name of the language used 87 | :param wrapper: an optional callable that should be used to wrap the 88 | result rows 89 | :param options: view specific options (e.g. {'collation':'raw'}) 90 | """ 91 | if design.startswith('_design/'): 92 | design = design[8:] 93 | self.design = design 94 | self.name = name 95 | if isinstance(map_fun, FunctionType): 96 | map_fun = _strip_decorators(getsource(map_fun).rstrip()) 97 | self.map_fun = dedent(map_fun.lstrip('\n')) 98 | if isinstance(reduce_fun, FunctionType): 99 | reduce_fun = _strip_decorators(getsource(reduce_fun).rstrip()) 100 | if reduce_fun: 101 | reduce_fun = dedent(reduce_fun.lstrip('\n')) 102 | self.reduce_fun = reduce_fun 103 | self.language = language 104 | self.wrapper = wrapper 105 | self.options = options 106 | self.defaults = defaults 107 | 108 | def __call__(self, db, **options): 109 | """Execute the view in the given database. 110 | 111 | :param db: the `Database` instance 112 | :param options: optional query string parameters 113 | :return: the view results 114 | :rtype: `ViewResults` 115 | """ 116 | wrapper = options.pop('wrapper', self.wrapper) 117 | merged_options = self.defaults.copy() 118 | merged_options.update(options) 119 | return db.view('/'.join([self.design, self.name]), 120 | wrapper=wrapper, **merged_options) 121 | 122 | def __repr__(self): 123 | return '<%s %r>' % (type(self).__name__, '/'.join([ 124 | '_design', self.design, '_view', self.name 125 | ])) 126 | 127 | def get_doc(self, db): 128 | """Retrieve and return the design document corresponding to this view 129 | definition from the given database. 130 | 131 | :param db: the `Database` instance 132 | :return: a `client.Document` instance, or `None` if the design document 133 | does not exist in the database 134 | :rtype: `Document` 135 | """ 136 | return db.get('_design/%s' % self.design) 137 | 138 | def sync(self, db): 139 | """Ensure that the view stored in the database matches the view defined 140 | by this instance. 141 | 142 | :param db: the `Database` instance 143 | """ 144 | return type(self).sync_many(db, [self]) 145 | 146 | @staticmethod 147 | def sync_many(db, views, remove_missing=False, callback=None): 148 | """Ensure that the views stored in the database that correspond to a 149 | given list of `ViewDefinition` instances match the code defined in 150 | those instances. 151 | 152 | This function might update more than one design document. This is done 153 | using the CouchDB bulk update feature to ensure atomicity of the 154 | operation. 155 | 156 | :param db: the `Database` instance 157 | :param views: a sequence of `ViewDefinition` instances 158 | :param remove_missing: whether views found in a design document that 159 | are not found in the list of `ViewDefinition` 160 | instances should be removed 161 | :param callback: a callback function that is invoked when a design 162 | document gets updated; the callback gets passed the 163 | design document as only parameter, before that doc 164 | has actually been saved back to the database 165 | """ 166 | docs = [] 167 | 168 | views = sorted(views, key=attrgetter('design')) 169 | for design, views in groupby(views, key=attrgetter('design')): 170 | doc_id = '_design/%s' % design 171 | doc = db.get(doc_id, {'_id': doc_id}) 172 | orig_doc = deepcopy(doc) 173 | languages = set() 174 | 175 | missing = list(doc.get('views', {}).keys()) 176 | for view in views: 177 | funcs = {'map': view.map_fun} 178 | if view.reduce_fun: 179 | funcs['reduce'] = view.reduce_fun 180 | if view.options: 181 | funcs['options'] = view.options 182 | doc.setdefault('views', {})[view.name] = funcs 183 | languages.add(view.language) 184 | if view.name in missing: 185 | missing.remove(view.name) 186 | 187 | if remove_missing and missing: 188 | for name in missing: 189 | del doc['views'][name] 190 | elif missing and 'language' in doc: 191 | languages.add(doc['language']) 192 | 193 | if len(languages) > 1: 194 | raise ValueError('Found different language views in one ' 195 | 'design document (%r)', list(languages)) 196 | doc['language'] = list(languages)[0] 197 | 198 | if doc != orig_doc: 199 | if callback is not None: 200 | callback(doc) 201 | docs.append(doc) 202 | 203 | return db.update(docs) 204 | 205 | 206 | def _strip_decorators(code): 207 | retval = [] 208 | beginning = True 209 | for line in code.splitlines(): 210 | if beginning and not line.isspace(): 211 | if line.lstrip().startswith('@'): 212 | continue 213 | beginning = False 214 | retval.append(line) 215 | return '\n'.join(retval) 216 | -------------------------------------------------------------------------------- /couchdb/tests/multipart.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2008-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 | import unittest 10 | 11 | from couchdb import multipart 12 | from couchdb.util import StringIO 13 | from couchdb.tests import testutil 14 | 15 | class ReadMultipartTestCase(unittest.TestCase): 16 | 17 | def test_flat(self): 18 | text = b'''\ 19 | Content-Type: multipart/mixed; boundary="===============1946781859==" 20 | 21 | --===============1946781859== 22 | Content-Type: application/json 23 | Content-ID: bar 24 | ETag: "1-4229094393" 25 | 26 | { 27 | "_id": "bar", 28 | "_rev": "1-4229094393" 29 | } 30 | --===============1946781859== 31 | Content-Type: application/json 32 | Content-ID: foo 33 | ETag: "1-2182689334" 34 | 35 | { 36 | "_id": "foo", 37 | "_rev": "1-2182689334", 38 | "something": "cool" 39 | } 40 | --===============1946781859==-- 41 | ''' 42 | num = 0 43 | parts = multipart.read_multipart(StringIO(text)) 44 | for headers, is_multipart, payload in parts: 45 | self.assertEqual(is_multipart, False) 46 | self.assertEqual('application/json', headers['content-type']) 47 | if num == 0: 48 | self.assertEqual('bar', headers['content-id']) 49 | self.assertEqual('"1-4229094393"', headers['etag']) 50 | self.assertEqual(b'{\n "_id": "bar",\n ' 51 | b'"_rev": "1-4229094393"\n}', payload) 52 | elif num == 1: 53 | self.assertEqual('foo', headers['content-id']) 54 | self.assertEqual('"1-2182689334"', headers['etag']) 55 | self.assertEqual(b'{\n "_id": "foo",\n "_rev": "1-2182689334",' 56 | b'\n "something": "cool"\n}', payload) 57 | num += 1 58 | self.assertEqual(num, 2) 59 | 60 | def test_nested(self): 61 | text = b'''\ 62 | Content-Type: multipart/mixed; boundary="===============1946781859==" 63 | 64 | --===============1946781859== 65 | Content-Type: application/json 66 | Content-ID: bar 67 | ETag: "1-4229094393" 68 | 69 | { 70 | "_id": "bar", 71 | "_rev": "1-4229094393" 72 | } 73 | --===============1946781859== 74 | Content-Type: multipart/mixed; boundary="===============0909101126==" 75 | Content-ID: foo 76 | ETag: "1-919589747" 77 | 78 | --===============0909101126== 79 | Content-Type: application/json 80 | 81 | { 82 | "_id": "foo", 83 | "_rev": "1-919589747", 84 | "something": "cool" 85 | } 86 | --===============0909101126== 87 | Content-Type: text/plain 88 | Content-ID: mail.txt 89 | 90 | Hello, friends. 91 | How are you doing? 92 | 93 | Regards, Chris 94 | --===============0909101126==-- 95 | --===============1946781859== 96 | Content-Type: application/json 97 | Content-ID: baz 98 | ETag: "1-3482142493" 99 | 100 | { 101 | "_id": "baz", 102 | "_rev": "1-3482142493" 103 | } 104 | --===============1946781859==-- 105 | ''' 106 | num = 0 107 | parts = multipart.read_multipart(StringIO(text)) 108 | for headers, is_multipart, payload in parts: 109 | if num == 0: 110 | self.assertEqual(is_multipart, False) 111 | self.assertEqual('application/json', headers['content-type']) 112 | self.assertEqual('bar', headers['content-id']) 113 | self.assertEqual('"1-4229094393"', headers['etag']) 114 | self.assertEqual(b'{\n "_id": "bar", \n ' 115 | b'"_rev": "1-4229094393"\n}', payload) 116 | elif num == 1: 117 | self.assertEqual(is_multipart, True) 118 | self.assertEqual('foo', headers['content-id']) 119 | self.assertEqual('"1-919589747"', headers['etag']) 120 | 121 | partnum = 0 122 | for headers, is_multipart, payload in payload: 123 | self.assertEqual(is_multipart, False) 124 | if partnum == 0: 125 | self.assertEqual('application/json', 126 | headers['content-type']) 127 | self.assertEqual(b'{\n "_id": "foo", \n "_rev": ' 128 | b'"1-919589747", \n "something": ' 129 | b'"cool"\n}', payload) 130 | elif partnum == 1: 131 | self.assertEqual('text/plain', headers['content-type']) 132 | self.assertEqual('mail.txt', headers['content-id']) 133 | self.assertEqual(b'Hello, friends.\nHow are you doing?' 134 | b'\n\nRegards, Chris', payload) 135 | 136 | partnum += 1 137 | 138 | elif num == 2: 139 | self.assertEqual(is_multipart, False) 140 | self.assertEqual('application/json', headers['content-type']) 141 | self.assertEqual('baz', headers['content-id']) 142 | self.assertEqual('"1-3482142493"', headers['etag']) 143 | self.assertEqual(b'{\n "_id": "baz", \n ' 144 | b'"_rev": "1-3482142493"\n}', payload) 145 | 146 | 147 | num += 1 148 | self.assertEqual(num, 3) 149 | 150 | def test_unicode_headers(self): 151 | # http://code.google.com/p/couchdb-python/issues/detail?id=179 152 | dump = u'''Content-Type: multipart/mixed; boundary="==123456789==" 153 | 154 | --==123456789== 155 | Content-ID: =?utf-8?b?5paH5qGj?= 156 | Content-Length: 63 157 | Content-MD5: Cpw3iC3xPua8YzKeWLzwvw== 158 | Content-Type: application/json 159 | 160 | {"_rev": "3-bc27b6930ca514527d8954c7c43e6a09", "_id": "文档"} 161 | ''' 162 | parts = multipart.read_multipart(StringIO(dump.encode('utf-8'))) 163 | for headers, is_multipart, payload in parts: 164 | self.assertEqual(headers['content-id'], u'文档') 165 | break 166 | 167 | 168 | class WriteMultipartTestCase(unittest.TestCase): 169 | 170 | def test_unicode_content(self): 171 | buf = StringIO() 172 | envelope = multipart.write_multipart(buf, boundary='==123456789==') 173 | envelope.add('text/plain', u'Iñtërnâtiônàlizætiøn') 174 | envelope.close() 175 | self.assertEqual(u'''Content-Type: multipart/mixed; boundary="==123456789==" 176 | 177 | --==123456789== 178 | Content-Length: 27 179 | Content-MD5: 5eYoIG5zsa5ps3/Gl2Kh4Q== 180 | Content-Type: text/plain;charset=utf-8 181 | 182 | Iñtërnâtiônàlizætiøn 183 | --==123456789==-- 184 | '''.encode('utf-8'), buf.getvalue().replace(b'\r\n', b'\n')) 185 | 186 | def test_unicode_content_ascii(self): 187 | buf = StringIO() 188 | envelope = multipart.write_multipart(buf, boundary='==123456789==') 189 | self.assertRaises(UnicodeEncodeError, envelope.add, 190 | 'text/plain;charset=ascii', u'Iñtërnâtiônàlizætiøn') 191 | 192 | def test_unicode_headers(self): 193 | # http://code.google.com/p/couchdb-python/issues/detail?id=179 194 | buf = StringIO() 195 | envelope = multipart.write_multipart(buf, boundary='==123456789==') 196 | envelope.add('application/json', 197 | '{"_rev": "3-bc27b6930ca514527d8954c7c43e6a09",' 198 | u' "_id": "文档"}', 199 | headers={'Content-ID': u"文档"}) 200 | self.assertEqual(u'''Content-Type: multipart/mixed; boundary="==123456789==" 201 | 202 | --==123456789== 203 | Content-ID: =?utf-8?b?5paH5qGj?= 204 | Content-Length: 63 205 | Content-MD5: Cpw3iC3xPua8YzKeWLzwvw== 206 | Content-Type: application/json;charset=utf-8 207 | 208 | {"_rev": "3-bc27b6930ca514527d8954c7c43e6a09", "_id": "文档"} 209 | '''.encode('utf-8'), buf.getvalue().replace(b'\r\n', b'\n')) 210 | 211 | def test_unicode_headers_charset(self): 212 | # http://code.google.com/p/couchdb-python/issues/detail?id=179 213 | buf = StringIO() 214 | envelope = multipart.write_multipart(buf, boundary='==123456789==') 215 | envelope.add('application/json;charset=utf-8', 216 | '{"_rev": "3-bc27b6930ca514527d8954c7c43e6a09",' 217 | ' "_id": "文档"}', 218 | headers={'Content-ID': u"文档"}) 219 | self.assertEqual(u'''Content-Type: multipart/mixed; boundary="==123456789==" 220 | 221 | --==123456789== 222 | Content-ID: =?utf-8?b?5paH5qGj?= 223 | Content-Length: 63 224 | Content-MD5: Cpw3iC3xPua8YzKeWLzwvw== 225 | Content-Type: application/json;charset=utf-8 226 | 227 | {"_rev": "3-bc27b6930ca514527d8954c7c43e6a09", "_id": "文档"} 228 | '''.encode('utf-8'), buf.getvalue().replace(b'\r\n', b'\n')) 229 | 230 | 231 | def suite(): 232 | suite = unittest.TestSuite() 233 | suite.addTest(testutil.doctest_suite(multipart)) 234 | suite.addTest(unittest.makeSuite(ReadMultipartTestCase, 'test')) 235 | suite.addTest(unittest.makeSuite(WriteMultipartTestCase, 'test')) 236 | return suite 237 | 238 | 239 | if __name__ == '__main__': 240 | unittest.main(defaultTest='suite') 241 | -------------------------------------------------------------------------------- /couchdb/multipart.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2008-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 | """Support for streamed reading and writing of multipart MIME content.""" 10 | 11 | from base64 import b64encode 12 | from cgi import parse_header 13 | from email import header 14 | 15 | try: 16 | from hashlib import md5 17 | except ImportError: 18 | from md5 import new as md5 19 | 20 | import uuid 21 | 22 | from couchdb import util 23 | 24 | __all__ = ['read_multipart', 'write_multipart'] 25 | __docformat__ = 'restructuredtext en' 26 | 27 | 28 | CRLF = b'\r\n' 29 | 30 | 31 | def read_multipart(fileobj, boundary=None): 32 | """Simple streaming MIME multipart parser. 33 | 34 | This function takes a file-like object reading a MIME envelope, and yields 35 | a ``(headers, is_multipart, payload)`` tuple for every part found, where 36 | ``headers`` is a dictionary containing the MIME headers of that part (with 37 | names lower-cased), ``is_multipart`` is a boolean indicating whether the 38 | part is itself multipart, and ``payload`` is either a string (if 39 | ``is_multipart`` is false), or an iterator over the nested parts. 40 | 41 | Note that the iterator produced for nested multipart payloads MUST be fully 42 | consumed, even if you wish to skip over the content. 43 | 44 | :param fileobj: a file-like object 45 | :param boundary: the part boundary string, will generally be determined 46 | automatically from the headers of the outermost multipart 47 | envelope 48 | :return: an iterator over the parts 49 | :since: 0.5 50 | """ 51 | headers = {} 52 | buf = [] 53 | outer = in_headers = boundary is None 54 | 55 | next_boundary = boundary and ('--' + boundary + '\n').encode('ascii') or None 56 | last_boundary = boundary and ('--' + boundary + '--\n').encode('ascii') or None 57 | 58 | def _current_part(): 59 | payload = b''.join(buf) 60 | if payload.endswith(b'\r\n'): 61 | payload = payload[:-2] 62 | elif payload.endswith(b'\n'): 63 | payload = payload[:-1] 64 | content_md5 = headers.get(b'content-md5') 65 | if content_md5: 66 | h = b64encode(md5(payload).digest()) 67 | if content_md5 != h: 68 | raise ValueError('data integrity check failed') 69 | return headers, False, payload 70 | 71 | for line in fileobj: 72 | if in_headers: 73 | line = line.replace(CRLF, b'\n') 74 | if line != b'\n': 75 | name, value = [item.strip() for item in line.split(b':', 1)] 76 | name = name.lower().decode('ascii') 77 | value, charset = header.decode_header(value.decode('utf-8'))[0] 78 | if charset is None: 79 | headers[name] = value 80 | else: 81 | headers[name] = value.decode(charset) 82 | else: 83 | in_headers = False 84 | mimetype, params = parse_header(headers.get('content-type')) 85 | if mimetype.startswith('multipart/'): 86 | sub_boundary = params['boundary'] 87 | sub_parts = read_multipart(fileobj, boundary=sub_boundary) 88 | if boundary is not None: 89 | yield headers, True, sub_parts 90 | headers.clear() 91 | del buf[:] 92 | else: 93 | for part in sub_parts: 94 | yield part 95 | return 96 | 97 | elif line.replace(CRLF, b'\n') == next_boundary: 98 | # We've reached the start of a new part, as indicated by the 99 | # boundary 100 | if headers: 101 | if not outer: 102 | yield _current_part() 103 | else: 104 | outer = False 105 | headers.clear() 106 | del buf[:] 107 | in_headers = True 108 | 109 | elif line.replace(CRLF, b'\n') == last_boundary: 110 | # We're done with this multipart envelope 111 | break 112 | 113 | else: 114 | buf.append(line) 115 | 116 | if not outer and headers: 117 | yield _current_part() 118 | 119 | 120 | class MultipartWriter(object): 121 | 122 | def __init__(self, fileobj, headers=None, subtype='mixed', boundary=None): 123 | self.fileobj = fileobj 124 | if boundary is None: 125 | boundary = '==' + uuid.uuid4().hex + '==' 126 | self.boundary = boundary 127 | if headers is None: 128 | headers = {} 129 | headers['Content-Type'] = 'multipart/%s; boundary="%s"' % ( 130 | subtype, self.boundary 131 | ) 132 | self._write_headers(headers) 133 | 134 | def open(self, headers=None, subtype='mixed', boundary=None): 135 | self.fileobj.write(b'--') 136 | self.fileobj.write(self.boundary.encode('utf-8')) 137 | self.fileobj.write(CRLF) 138 | return MultipartWriter(self.fileobj, headers=headers, subtype=subtype, 139 | boundary=boundary) 140 | 141 | def add(self, mimetype, content, headers=None): 142 | self.fileobj.write(b'--') 143 | self.fileobj.write(self.boundary.encode('utf-8')) 144 | self.fileobj.write(CRLF) 145 | if headers is None: 146 | headers = {} 147 | 148 | ctype, params = parse_header(mimetype) 149 | if isinstance(content, util.utype): 150 | if 'charset' in params: 151 | content = content.encode(params['charset']) 152 | else: 153 | content = content.encode('utf-8') 154 | mimetype = mimetype + ';charset=utf-8' 155 | 156 | headers['Content-Type'] = mimetype 157 | if content: 158 | headers['Content-Length'] = str(len(content)) 159 | hash = b64encode(md5(content).digest()).decode('ascii') 160 | headers['Content-MD5'] = hash 161 | self._write_headers(headers) 162 | if content: 163 | # XXX: throw an exception if a boundary appears in the content?? 164 | self.fileobj.write(content) 165 | self.fileobj.write(CRLF) 166 | 167 | def close(self): 168 | self.fileobj.write(b'--') 169 | self.fileobj.write(self.boundary.encode('ascii')) 170 | self.fileobj.write(b'--') 171 | self.fileobj.write(CRLF) 172 | 173 | def _write_headers(self, headers): 174 | if headers: 175 | for name in sorted(headers.keys()): 176 | value = headers[name] 177 | if value.encode('ascii', 'ignore') != value.encode('utf-8'): 178 | value = header.make_header([(value, 'utf-8')]).encode() 179 | self.fileobj.write(name.encode('utf-8')) 180 | self.fileobj.write(b': ') 181 | self.fileobj.write(value.encode('utf-8')) 182 | self.fileobj.write(CRLF) 183 | self.fileobj.write(CRLF) 184 | 185 | def __enter__(self): 186 | return self 187 | 188 | def __exit__(self, exc_type, exc_val, exc_tb): 189 | self.close() 190 | 191 | 192 | def write_multipart(fileobj, subtype='mixed', boundary=None): 193 | r"""Simple streaming MIME multipart writer. 194 | 195 | This function returns a `MultipartWriter` object that has a few methods to 196 | control the nested MIME parts. For example, to write a flat multipart 197 | envelope you call the ``add(mimetype, content, [headers])`` method for 198 | every part, and finally call the ``close()`` method. 199 | 200 | >>> from couchdb.util import StringIO 201 | 202 | >>> buf = StringIO() 203 | >>> envelope = write_multipart(buf, boundary='==123456789==') 204 | >>> envelope.add('text/plain', b'Just testing') 205 | >>> envelope.close() 206 | >>> print(buf.getvalue().replace(b'\r\n', b'\n').decode('utf-8')) 207 | Content-Type: multipart/mixed; boundary="==123456789==" 208 | 209 | --==123456789== 210 | Content-Length: 12 211 | Content-MD5: nHmX4a6el41B06x2uCpglQ== 212 | Content-Type: text/plain 213 | 214 | Just testing 215 | --==123456789==-- 216 | 217 | 218 | Note that an explicit boundary is only specified for testing purposes. If 219 | the `boundary` parameter is omitted, the multipart writer will generate a 220 | random string for the boundary. 221 | 222 | To write nested structures, call the ``open([headers])`` method on the 223 | respective envelope, and finish each envelope using the ``close()`` method: 224 | 225 | >>> buf = StringIO() 226 | >>> envelope = write_multipart(buf, boundary='==123456789==') 227 | >>> part = envelope.open(boundary='==abcdefghi==') 228 | >>> part.add('text/plain', u'Just testing') 229 | >>> part.close() 230 | >>> envelope.close() 231 | >>> print(buf.getvalue().replace(b'\r\n', b'\n').decode('utf-8')) #:doctest +ELLIPSIS 232 | Content-Type: multipart/mixed; boundary="==123456789==" 233 | 234 | --==123456789== 235 | Content-Type: multipart/mixed; boundary="==abcdefghi==" 236 | 237 | --==abcdefghi== 238 | Content-Length: 12 239 | Content-MD5: nHmX4a6el41B06x2uCpglQ== 240 | Content-Type: text/plain;charset=utf-8 241 | 242 | Just testing 243 | --==abcdefghi==-- 244 | --==123456789==-- 245 | 246 | 247 | :param fileobj: a writable file-like object that the output should get 248 | written to 249 | :param subtype: the subtype of the multipart MIME type (e.g. "mixed") 250 | :param boundary: the boundary to use to separate the different parts 251 | :since: 0.6 252 | """ 253 | return MultipartWriter(fileobj, subtype=subtype, boundary=boundary) 254 | -------------------------------------------------------------------------------- /couchdb/tests/couch_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright (C) 2007-2008 Christopher Lenz 5 | # All rights reserved. 6 | # 7 | # This software is licensed as described in the file COPYING, which 8 | # you should have received as part of this distribution. 9 | 10 | import unittest 11 | 12 | from couchdb.http import ResourceConflict, ResourceNotFound 13 | from couchdb.tests import testutil 14 | 15 | 16 | class CouchTests(testutil.TempDatabaseMixin, unittest.TestCase): 17 | 18 | def _create_test_docs(self, num): 19 | for i in range(num): 20 | self.db[str(i)] = {'a': i + 1, 'b': (i + 1) ** 2} 21 | 22 | def test_basics(self): 23 | self.assertEqual(0, len(self.db)) 24 | 25 | # create a document 26 | data = {'a': 1, 'b': 1} 27 | self.db['0'] = data 28 | self.assertEqual('0', data['_id']) 29 | assert '_rev' in data 30 | doc = self.db['0'] 31 | self.assertEqual('0', doc.id) 32 | self.assertEqual(data['_rev'], doc.rev) 33 | self.assertEqual(1, len(self.db)) 34 | 35 | # delete a document 36 | del self.db['0'] 37 | self.assertRaises(ResourceNotFound, self.db.__getitem__, '0') 38 | 39 | # test _all_docs 40 | self._create_test_docs(4) 41 | self.assertEqual(4, len(self.db)) 42 | for doc_id in self.db: 43 | assert int(doc_id) in range(4) 44 | 45 | # test a simple query 46 | query = """function(doc) { 47 | if (doc.a==4) 48 | emit(null, doc.b); 49 | }""" 50 | result = list(self.db.query(query)) 51 | self.assertEqual(1, len(result)) 52 | self.assertEqual('3', result[0].id) 53 | self.assertEqual(16, result[0].value) 54 | 55 | # modify a document, and redo the query 56 | doc = self.db['0'] 57 | doc['a'] = 4 58 | self.db['0'] = doc 59 | result = list(self.db.query(query)) 60 | self.assertEqual(2, len(result)) 61 | 62 | # add more documents, and redo the query again 63 | self.db.save({'a': 3, 'b': 9}) 64 | self.db.save({'a': 4, 'b': 16}) 65 | result = list(self.db.query(query)) 66 | self.assertEqual(3, len(result)) 67 | self.assertEqual(6, len(self.db)) 68 | 69 | # delete a document, and redo the query once more 70 | del self.db['0'] 71 | result = list(self.db.query(query)) 72 | self.assertEqual(2, len(result)) 73 | self.assertEqual(5, len(self.db)) 74 | 75 | def test_conflict_detection(self): 76 | doc1 = {'a': 1, 'b': 1} 77 | self.db['foo'] = doc1 78 | doc2 = self.db['foo'] 79 | self.assertEqual(doc1['_id'], doc2.id) 80 | self.assertEqual(doc1['_rev'], doc2.rev) 81 | 82 | # make conflicting modifications 83 | doc1['a'] = 2 84 | doc2['a'] = 3 85 | self.db['foo'] = doc1 86 | self.assertRaises(ResourceConflict, self.db.__setitem__, 'foo', doc2) 87 | 88 | # try submitting without the revision info 89 | data = {'_id': 'foo', 'a': 3, 'b': 1} 90 | self.assertRaises(ResourceConflict, self.db.__setitem__, 'foo', data) 91 | 92 | del self.db['foo'] 93 | self.db['foo'] = data 94 | 95 | def test_lots_of_docs(self): 96 | num = 100 # Crank up manually to really test 97 | for i in range(num): 98 | self.db[str(i)] = {'integer': i, 'string': str(i)} 99 | self.assertEqual(num, len(self.db)) 100 | 101 | query = """function(doc) { 102 | emit(doc.integer, null); 103 | }""" 104 | results = list(self.db.query(query)) 105 | self.assertEqual(num, len(results)) 106 | for idx, row in enumerate(results): 107 | self.assertEqual(idx, row.key) 108 | 109 | results = list(self.db.query(query, descending=True)) 110 | self.assertEqual(num, len(results)) 111 | for idx, row in enumerate(results): 112 | self.assertEqual(num - idx - 1, row.key) 113 | 114 | def test_multiple_rows(self): 115 | self.db['NC'] = {'cities': ["Charlotte", "Raleigh"]} 116 | self.db['MA'] = {'cities': ["Boston", "Lowell", "Worcester", 117 | "Cambridge", "Springfield"]} 118 | self.db['FL'] = {'cities': ["Miami", "Tampa", "Orlando", 119 | "Springfield"]} 120 | 121 | query = """function(doc){ 122 | for (var i = 0; i < doc.cities.length; i++) { 123 | emit(doc.cities[i] + ", " + doc._id, null); 124 | } 125 | }""" 126 | results = list(self.db.query(query)) 127 | self.assertEqual(11, len(results)) 128 | self.assertEqual("Boston, MA", results[0].key); 129 | self.assertEqual("Cambridge, MA", results[1].key); 130 | self.assertEqual("Charlotte, NC", results[2].key); 131 | self.assertEqual("Lowell, MA", results[3].key); 132 | self.assertEqual("Miami, FL", results[4].key); 133 | self.assertEqual("Orlando, FL", results[5].key); 134 | self.assertEqual("Raleigh, NC", results[6].key); 135 | self.assertEqual("Springfield, FL", results[7].key); 136 | self.assertEqual("Springfield, MA", results[8].key); 137 | self.assertEqual("Tampa, FL", results[9].key); 138 | self.assertEqual("Worcester, MA", results[10].key); 139 | 140 | # Add a city and rerun the query 141 | doc = self.db['NC'] 142 | doc['cities'].append("Wilmington") 143 | self.db['NC'] = doc 144 | results = list(self.db.query(query)) 145 | self.assertEqual(12, len(results)) 146 | self.assertEqual("Wilmington, NC", results[10].key) 147 | 148 | # Remove a document and redo the query again 149 | del self.db['MA'] 150 | results = list(self.db.query(query)) 151 | self.assertEqual(7, len(results)) 152 | self.assertEqual("Charlotte, NC", results[0].key); 153 | self.assertEqual("Miami, FL", results[1].key); 154 | self.assertEqual("Orlando, FL", results[2].key); 155 | self.assertEqual("Raleigh, NC", results[3].key); 156 | self.assertEqual("Springfield, FL", results[4].key); 157 | self.assertEqual("Tampa, FL", results[5].key); 158 | self.assertEqual("Wilmington, NC", results[6].key) 159 | 160 | def test_large_docs(self): 161 | size = 100 162 | longtext = '0123456789\n' * size 163 | self.db.save({'longtext': longtext}) 164 | self.db.save({'longtext': longtext}) 165 | self.db.save({'longtext': longtext}) 166 | self.db.save({'longtext': longtext}) 167 | 168 | query = """function(doc) { 169 | emit(null, doc.longtext); 170 | }""" 171 | results = list(self.db.query(query)) 172 | self.assertEqual(4, len(results)) 173 | 174 | def test_utf8_encoding(self): 175 | texts = [ 176 | u"1. Ascii: hello", 177 | u"2. Russian: На берегу пустынных волн", 178 | u"3. Math: ∮ E⋅da = Q, n → ∞, ∑ f(i) = ∏ g(i),", 179 | u"4. Geek: STARGΛ̊TE SG-1", 180 | u"5. Braille: ⡌⠁⠧⠑ ⠼⠁⠒ ⡍⠜⠇⠑⠹⠰⠎ ⡣⠕⠌" 181 | ] 182 | for idx, text in enumerate(texts): 183 | self.db[str(idx)] = {'text': text} 184 | for idx, text in enumerate(texts): 185 | doc = self.db[str(idx)] 186 | self.assertEqual(text, doc['text']) 187 | 188 | query = """function(doc) { 189 | emit(doc.text, null); 190 | }""" 191 | for idx, row in enumerate(self.db.query(query)): 192 | self.assertEqual(texts[idx], row.key) 193 | 194 | def test_update_with_unsafe_doc_ids(self): 195 | doc_id = 'sanitise/the/doc/id/plz/' 196 | design_doc = 'test_slashes_in_doc_ids' 197 | handler_name = 'test' 198 | func = """ 199 | function(doc, req) { 200 | doc.test = 'passed'; 201 | return [doc, 'ok']; 202 | } 203 | """ 204 | 205 | # Stick an update handler in 206 | self.db['_design/%s' % design_doc] = { 207 | 'updates': {handler_name: func} 208 | } 209 | # And a test doc 210 | self.db[doc_id] = {'test': 'failed'} 211 | 212 | response = self.db.update_doc( 213 | '%s/%s' % (design_doc, handler_name), 214 | docid=doc_id 215 | ) 216 | 217 | self.assertEqual(self.db[doc_id]['test'], 'passed') 218 | 219 | def test_design_docs(self): 220 | for i in range(50): 221 | self.db[str(i)] = {'integer': i, 'string': str(i)} 222 | self.db['_design/test'] = {'views': { 223 | 'all_docs': {'map': 'function(doc) { emit(doc.integer, null) }'}, 224 | 'no_docs': {'map': 'function(doc) {}'}, 225 | 'single_doc': {'map': 'function(doc) { if (doc._id == "1") emit(null, 1) }'} 226 | }} 227 | for idx, row in enumerate(self.db.view('test/all_docs')): 228 | self.assertEqual(idx, row.key) 229 | self.assertEqual(0, len(list(self.db.view('test/no_docs')))) 230 | self.assertEqual(1, len(list(self.db.view('test/single_doc')))) 231 | 232 | def test_collation(self): 233 | values = [ 234 | None, False, True, 235 | 1, 2, 3.0, 4, 236 | 'a', 'A', 'aa', 'b', 'B', 'ba', 'bb', 237 | ['a'], ['b'], ['b', 'c'], ['b', 'c', 'a'], ['b', 'd'], 238 | ['b', 'd', 'e'], 239 | {'a': 1}, {'a': 2}, {'b': 1}, {'b': 2}, {'b': 2, 'c': 2}, 240 | ] 241 | self.db['0'] = {'bar': 0} 242 | for idx, value in enumerate(values): 243 | self.db[str(idx + 1)] = {'foo': value} 244 | 245 | query = """function(doc) { 246 | if(doc.foo !== undefined) { 247 | emit(doc.foo, null); 248 | } 249 | }""" 250 | rows = iter(self.db.query(query)) 251 | self.assertEqual(None, next(rows).value) 252 | for idx, row in enumerate(rows): 253 | self.assertEqual(values[idx + 1], row.key) 254 | 255 | rows = self.db.query(query, descending=True) 256 | for idx, row in enumerate(rows): 257 | if idx < len(values): 258 | self.assertEqual(values[len(values) - 1- idx], row.key) 259 | else: 260 | self.assertEqual(None, row.value) 261 | 262 | for value in values: 263 | rows = list(self.db.query(query, key=value)) 264 | self.assertEqual(1, len(rows)) 265 | self.assertEqual(value, rows[0].key) 266 | 267 | 268 | def suite(): 269 | suite = unittest.TestSuite() 270 | suite.addTest(unittest.makeSuite(CouchTests, 'test')) 271 | return suite 272 | 273 | if __name__ == '__main__': 274 | unittest.main(defaultTest='suite') 275 | -------------------------------------------------------------------------------- /couchdb/tests/mapping.py: -------------------------------------------------------------------------------- 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 | from decimal import Decimal 10 | import unittest 11 | 12 | from couchdb import design, mapping 13 | from couchdb.tests import testutil 14 | from datetime import datetime 15 | 16 | class DocumentTestCase(testutil.TempDatabaseMixin, unittest.TestCase): 17 | 18 | def test_mutable_fields(self): 19 | class Test(mapping.Document): 20 | d = mapping.DictField() 21 | a = Test() 22 | b = Test() 23 | a.d['x'] = True 24 | self.assertTrue(a.d.get('x')) 25 | self.assertFalse(b.d.get('x')) 26 | 27 | def test_automatic_id(self): 28 | class Post(mapping.Document): 29 | title = mapping.TextField() 30 | post = Post(title='Foo bar') 31 | assert post.id is None 32 | post.store(self.db) 33 | assert post.id is not None 34 | self.assertEqual('Foo bar', self.db[post.id]['title']) 35 | 36 | def test_explicit_id_via_init(self): 37 | class Post(mapping.Document): 38 | title = mapping.TextField() 39 | post = Post(id='foo_bar', title='Foo bar') 40 | self.assertEqual('foo_bar', post.id) 41 | post.store(self.db) 42 | self.assertEqual('Foo bar', self.db['foo_bar']['title']) 43 | 44 | def test_explicit_id_via_setter(self): 45 | class Post(mapping.Document): 46 | title = mapping.TextField() 47 | post = Post(title='Foo bar') 48 | post.id = 'foo_bar' 49 | self.assertEqual('foo_bar', post.id) 50 | post.store(self.db) 51 | self.assertEqual('Foo bar', self.db['foo_bar']['title']) 52 | 53 | def test_change_id_failure(self): 54 | class Post(mapping.Document): 55 | title = mapping.TextField() 56 | post = Post(title='Foo bar') 57 | post.store(self.db) 58 | post = Post.load(self.db, post.id) 59 | try: 60 | post.id = 'foo_bar' 61 | self.fail('Excepted AttributeError') 62 | except AttributeError as e: 63 | self.assertEqual('id can only be set on new documents', e.args[0]) 64 | 65 | def test_batch_update(self): 66 | class Post(mapping.Document): 67 | title = mapping.TextField() 68 | post1 = Post(title='Foo bar') 69 | post2 = Post(title='Foo baz') 70 | results = self.db.update([post1, post2]) 71 | self.assertEqual(2, len(results)) 72 | assert results[0][0] is True 73 | assert results[1][0] is True 74 | 75 | def test_store_existing(self): 76 | class Post(mapping.Document): 77 | title = mapping.TextField() 78 | post = Post(title='Foo bar') 79 | post.store(self.db) 80 | post.store(self.db) 81 | self.assertEqual(len(list(self.db.view('_all_docs'))), 1) 82 | 83 | def test_old_datetime(self): 84 | dt = mapping.DateTimeField() 85 | assert dt._to_python('1880-01-01T00:00:00Z') 86 | 87 | def test_datetime_with_microseconds(self): 88 | dt = mapping.DateTimeField() 89 | assert dt._to_python('2016-06-09T21:21:49.739248Z') 90 | 91 | def test_datetime_to_json(self): 92 | dt = mapping.DateTimeField() 93 | d = datetime.now() 94 | assert dt._to_json(d) 95 | 96 | def test_get_has_default(self): 97 | doc = mapping.Document() 98 | doc.get('foo') 99 | doc.get('foo', None) 100 | 101 | 102 | class ListFieldTestCase(testutil.TempDatabaseMixin, unittest.TestCase): 103 | 104 | def test_to_json(self): 105 | # See 106 | class Post(mapping.Document): 107 | title = mapping.TextField() 108 | comments = mapping.ListField(mapping.DictField( 109 | mapping.Mapping.build( 110 | author = mapping.TextField(), 111 | content = mapping.TextField(), 112 | ) 113 | )) 114 | post = Post(title='Foo bar') 115 | post.comments.append(author='myself', content='Bla bla') 116 | post.comments = post.comments 117 | self.assertEqual([{'content': 'Bla bla', 'author': 'myself'}], 118 | post.comments) 119 | 120 | def test_proxy_append(self): 121 | class Thing(mapping.Document): 122 | numbers = mapping.ListField(mapping.DecimalField) 123 | thing = Thing(numbers=[Decimal('1.0'), Decimal('2.0')]) 124 | thing.numbers.append(Decimal('3.0')) 125 | self.assertEqual(3, len(thing.numbers)) 126 | self.assertEqual(Decimal('3.0'), thing.numbers[2]) 127 | 128 | def test_proxy_append_kwargs(self): 129 | class Thing(mapping.Document): 130 | numbers = mapping.ListField(mapping.DecimalField) 131 | thing = Thing() 132 | self.assertRaises(TypeError, thing.numbers.append, foo='bar') 133 | 134 | def test_proxy_contains(self): 135 | class Thing(mapping.Document): 136 | numbers = mapping.ListField(mapping.DecimalField) 137 | thing = Thing(numbers=[Decimal('1.0'), Decimal('2.0')]) 138 | assert isinstance(thing.numbers, mapping.ListField.Proxy) 139 | assert '1.0' not in thing.numbers 140 | assert Decimal('1.0') in thing.numbers 141 | 142 | def test_proxy_count(self): 143 | class Thing(mapping.Document): 144 | numbers = mapping.ListField(mapping.DecimalField) 145 | thing = Thing(numbers=[Decimal('1.0'), Decimal('2.0')]) 146 | self.assertEqual(1, thing.numbers.count(Decimal('1.0'))) 147 | self.assertEqual(0, thing.numbers.count('1.0')) 148 | 149 | def test_proxy_index(self): 150 | class Thing(mapping.Document): 151 | numbers = mapping.ListField(mapping.DecimalField) 152 | thing = Thing(numbers=[Decimal('1.0'), Decimal('2.0')]) 153 | self.assertEqual(0, thing.numbers.index(Decimal('1.0'))) 154 | self.assertRaises(ValueError, thing.numbers.index, '3.0') 155 | 156 | def test_proxy_insert(self): 157 | class Thing(mapping.Document): 158 | numbers = mapping.ListField(mapping.DecimalField) 159 | thing = Thing(numbers=[Decimal('1.0'), Decimal('2.0')]) 160 | thing.numbers.insert(0, Decimal('0.0')) 161 | self.assertEqual(3, len(thing.numbers)) 162 | self.assertEqual(Decimal('0.0'), thing.numbers[0]) 163 | 164 | def test_proxy_insert_kwargs(self): 165 | class Thing(mapping.Document): 166 | numbers = mapping.ListField(mapping.DecimalField) 167 | thing = Thing() 168 | self.assertRaises(TypeError, thing.numbers.insert, 0, foo='bar') 169 | 170 | def test_proxy_remove(self): 171 | class Thing(mapping.Document): 172 | numbers = mapping.ListField(mapping.DecimalField) 173 | thing = Thing() 174 | thing.numbers.append(Decimal('1.0')) 175 | thing.numbers.remove(Decimal('1.0')) 176 | 177 | def test_proxy_iter(self): 178 | class Thing(mapping.Document): 179 | numbers = mapping.ListField(mapping.DecimalField) 180 | self.db['test'] = {'numbers': ['1.0', '2.0']} 181 | thing = Thing.load(self.db, 'test') 182 | assert isinstance(thing.numbers[0], Decimal) 183 | 184 | def test_proxy_iter_dict(self): 185 | class Post(mapping.Document): 186 | comments = mapping.ListField(mapping.DictField) 187 | self.db['test'] = {'comments': [{'author': 'Joe', 'content': 'Hey'}]} 188 | post = Post.load(self.db, 'test') 189 | assert isinstance(post.comments[0], dict) 190 | 191 | def test_proxy_pop(self): 192 | class Thing(mapping.Document): 193 | numbers = mapping.ListField(mapping.DecimalField) 194 | thing = Thing() 195 | thing.numbers = [Decimal('%d' % i) for i in range(3)] 196 | self.assertEqual(thing.numbers.pop(), Decimal('2.0')) 197 | self.assertEqual(len(thing.numbers), 2) 198 | self.assertEqual(thing.numbers.pop(0), Decimal('0.0')) 199 | 200 | def test_proxy_slices(self): 201 | class Thing(mapping.Document): 202 | numbers = mapping.ListField(mapping.DecimalField) 203 | thing = Thing() 204 | thing.numbers = [Decimal('%d' % i) for i in range(5)] 205 | ll = thing.numbers[1:3] 206 | self.assertEqual(len(ll), 2) 207 | self.assertEqual(ll[0], Decimal('1.0')) 208 | thing.numbers[2:4] = [Decimal('%d' % i) for i in range(6, 8)] 209 | self.assertEqual(thing.numbers[2], Decimal('6.0')) 210 | self.assertEqual(thing.numbers[4], Decimal('4.0')) 211 | self.assertEqual(len(thing.numbers), 5) 212 | del thing.numbers[3:] 213 | self.assertEqual(len(thing.numbers), 3) 214 | 215 | def test_mutable_fields(self): 216 | class Thing(mapping.Document): 217 | numbers = mapping.ListField(mapping.DecimalField) 218 | thing = Thing.wrap({'_id': 'foo', '_rev': 1}) # no numbers 219 | thing.numbers.append('1.0') 220 | thing2 = Thing(id='thing2') 221 | self.assertEqual([i for i in thing2.numbers], []) 222 | 223 | 224 | all_map_func = 'function(doc) { emit(doc._id, doc); }' 225 | 226 | 227 | class WrappingTestCase(testutil.TempDatabaseMixin, unittest.TestCase): 228 | 229 | class Item(mapping.Document): 230 | with_include_docs = mapping.ViewField('test', all_map_func, 231 | include_docs=True) 232 | without_include_docs = mapping.ViewField('test', all_map_func) 233 | 234 | def setUp(self): 235 | super(WrappingTestCase, self).setUp() 236 | design.ViewDefinition.sync_many( 237 | self.db, [self.Item.with_include_docs, 238 | self.Item.without_include_docs]) 239 | 240 | def test_viewfield_property(self): 241 | self.Item().store(self.db) 242 | results = self.Item.with_include_docs(self.db) 243 | self.assertEqual(type(results.rows[0]), self.Item) 244 | results = self.Item.without_include_docs(self.db) 245 | self.assertEqual(type(results.rows[0]), self.Item) 246 | 247 | def test_view(self): 248 | self.Item().store(self.db) 249 | results = self.Item.view(self.db, 'test/without_include_docs') 250 | self.assertEqual(type(results.rows[0]), self.Item) 251 | results = self.Item.view(self.db, 'test/without_include_docs', 252 | include_docs=True) 253 | self.assertEqual(type(results.rows[0]), self.Item) 254 | 255 | def test_wrapped_view(self): 256 | self.Item().store(self.db) 257 | results = self.db.view('_all_docs', wrapper=self.Item._wrap_row) 258 | doc = results.rows[0] 259 | self.db.delete(doc) 260 | 261 | def test_query(self): 262 | self.Item().store(self.db) 263 | results = self.Item.query(self.db, all_map_func, None) 264 | self.assertEqual(type(results.rows[0]), self.Item) 265 | results = self.Item.query(self.db, all_map_func, None, include_docs=True) 266 | self.assertEqual(type(results.rows[0]), self.Item) 267 | 268 | 269 | def suite(): 270 | suite = unittest.TestSuite() 271 | suite.addTest(testutil.doctest_suite(mapping)) 272 | suite.addTest(unittest.makeSuite(DocumentTestCase, 'test')) 273 | suite.addTest(unittest.makeSuite(ListFieldTestCase, 'test')) 274 | suite.addTest(unittest.makeSuite(WrappingTestCase, 'test')) 275 | return suite 276 | 277 | 278 | if __name__ == '__main__': 279 | unittest.main(defaultTest='suite') 280 | -------------------------------------------------------------------------------- /ChangeLog.rst: -------------------------------------------------------------------------------- 1 | Version 1.2 (2018-02-09) 2 | ------------------------ 3 | 4 | * Fixed some issues relating to usage with Python 3 5 | * Remove support for Python 2.6 and 3.x with x < 4 6 | * Fix logging response in query server (fixes #321) 7 | * Fix HTTP authentication password encoding (fixes #302) 8 | * Add missing ``http.Forbidden`` error (fixes #305) 9 | * Show ``doc`` property on ``Row`` string representation 10 | * Add methods for mango queries and indexes 11 | * Allow mango filters in ``_changes`` API 12 | 13 | 14 | Version 1.1 (2016-08-05) 15 | ------------------------ 16 | 17 | * Add script to load design documents from disk 18 | * Add methods on ``Server`` for user/session management 19 | * Add microseconds support for DateTimeFields 20 | * Handle changes feed as emitted by CouchBase (fixes #289) 21 | * Support Python 3 in ``couchdb-dump`` script (fixes #296) 22 | * Expand relative URLs from Location headers (fixes #287) 23 | * Correctly handle ``_rev`` fields in mapped documents (fixes #278) 24 | 25 | 26 | Version 1.0.1 (2016-03-12) 27 | -------------------------- 28 | 29 | * Make sure connections are correctly closed on GAE (fixes #224) 30 | * Correctly join path parts in replicate script (fixes #269) 31 | * Fix id and rev for some special documents 32 | * Make it possible to disable SSL verification 33 | 34 | 35 | Version 1.0 (2014-11-16) 36 | ------------------------ 37 | 38 | * Many smaller Python 3 compatibility issues have been fixed 39 | * Improve handling of binary attachments in the ``couchdb-dump`` tool 40 | * Added testing via tox and support for Travis CI 41 | 42 | 43 | Version 0.10 (2014-07-15) 44 | ------------------------- 45 | 46 | * Now compatible with Python 2.7, 3.3 and 3.4 47 | * Added batch processing for the ``couchdb-dump`` tool 48 | * A very basic API to access the ``_security`` object 49 | * A way to access the ``update_seq`` value on view results 50 | 51 | 52 | Version 0.9 (2013-04-25) 53 | ------------------------ 54 | 55 | * Don't validate database names on the client side. This means some methods 56 | dealing with database names can return different exceptions than before. 57 | * Use HTTP socket more efficiently to avoid the Nagle algorithm, greatly 58 | improving performace. Note: add the ``{nodelay, true}`` option to the CouchDB 59 | server's httpd/socket_options config. 60 | * Add support for show and list functions. 61 | * Add support for calling update handlers. 62 | * Add support for purging documents. 63 | * Add ``iterview()`` for more efficient iteration over large view results. 64 | * Add view cleanup API. 65 | * Enhance ``Server.stats()`` to optionally retrieve a single set of statistics. 66 | * Implement ``Session`` timeouts. 67 | * Add ``error`` property to ``Row`` objects. 68 | * Add ``default=None`` arg to ``mapping.Document.get()`` to make it a little more 69 | dict-like. 70 | * Enhance ``Database.info()`` so it can also be used to get info for a design 71 | doc. 72 | * Add view definition options, e.g. collation. 73 | * Fix support for authentication in dump/load tools. 74 | * Support non-ASCII document IDs in serialization format. 75 | * Protect ``ResponseBody`` from being iterated/closed multiple times. 76 | * Rename iteration method for ResponseBody chunks to ``iterchunks()`` to 77 | prevent usage for non-chunked responses. 78 | * JSON encoding exceptions are no longer masked, resulting in better error 79 | messages. 80 | * ``cjson`` support is now deprecated. 81 | * Fix ``Row.value`` and ``Row.__repr__`` to never raise exceptions. 82 | * Fix Python view server's reduce to handle empty map results list. 83 | * Use locale-independent timestamp identifiers for HTTP cache. 84 | * Don't require setuptools/distribute to install the core package. (Still 85 | needed to install the console scripts.) 86 | 87 | 88 | Version 0.8 (Aug 13, 2010) 89 | -------------------------- 90 | 91 | * The couchdb-replicate script has changed from being a poor man's version of 92 | continuous replication (predating it) to being a simple script to help 93 | kick off replication jobs across databases and servers. 94 | * Reinclude all http exception types in the 'couchdb' package's scope. 95 | * Replaced epydoc API docs by more extensive Sphinx-based documentation. 96 | * Request retries schedule and frequency are now customizable. 97 | * Allow more kinds of request errors to trigger a retry. 98 | * Improve wrapping of view results. 99 | * Added a ``uuids()`` method to the ``client.Server`` class (issue 122). 100 | * Tested with CouchDB 0.10 - 1.0 (and Python 2.4 - 2.7). 101 | 102 | 103 | Version 0.7.0 (Apr 15, 2010) 104 | ---------------------------- 105 | 106 | * Breaking change: the dependency on ``httplib2`` has been replaced by 107 | an internal ``couchdb.http`` library. This changes the API in several places. 108 | Most importantly, ``resource.request()`` now returns a 3-member tuple. 109 | * Breaking change: ``couchdb.schema`` has been renamed to ``couchdb.mapping``. 110 | This better reflects what is actually provided. Classes inside 111 | ``couchdb.mapping`` have been similarly renamed (e.g. ``Schema`` -> ``Mapping``). 112 | * Breaking change: ``couchdb.schema.View`` has been renamed to 113 | ``couchdb.mapping.ViewField``, in order to help distinguish it from 114 | ``couchdb.client.View``. 115 | * Breaking change: the ``client.Server`` properties ``version`` and ``config`` 116 | have become methods in order to improve API consistency. 117 | * Prevent ``schema.ListField`` objects from sharing the same default (issue 107). 118 | * Added a ``changes()`` method to the ``client.Database`` class (issue 103). 119 | * Added an optional argument to the 'Database.compact`` method to enable 120 | view compaction (the rest of issue 37). 121 | 122 | 123 | Version 0.6.1 (Dec 14, 2009) 124 | ---------------------------- 125 | 126 | * Compatible with CouchDB 0.9.x and 0.10.x. 127 | * Removed debugging statement from ``json`` module (issue 82). 128 | * Fixed a few bugs resulting from typos. 129 | * Added a ``replicate()`` method to the ``client.Server`` class (issue 61). 130 | * Honor the boundary argument in the dump script code (issue 100). 131 | * Added a ``stats()`` method to the ``client.Server`` class. 132 | * Added a ``tasks()`` method to the ``client.Server`` class. 133 | * Allow slashes in path components passed to the uri function (issue 96). 134 | * ``schema.DictField`` objects now have a separate backing dictionary for each 135 | instance of their ``schema.Document`` (issue 101). 136 | * ``schema.ListField`` proxy objects now have a more consistent (though somewhat 137 | slower) ``count()`` method (issue 91). 138 | * ``schema.ListField`` objects now have correct behavior for slicing operations 139 | and the ``pop()`` method (issue 92). 140 | * Added a ``revisions()`` method to the Database class (issue 99). 141 | * Make sure we always return UTF-8 from the view server (issue 81). 142 | 143 | 144 | Version 0.6 (Jul 2, 2009) 145 | ------------------------- 146 | 147 | * Compatible with CouchDB 0.9.x. 148 | * ``schema.DictField`` instances no longer need to be bound to a ``Schema`` 149 | (issue 51). 150 | * Added a ``config`` property to the ``client.Server`` class (issue 67). 151 | * Added a ``compact()`` method to the ``client.Database`` class (issue 37). 152 | * Changed the ``update()`` method of the ``client.Database`` class to simplify 153 | the handling of errors. The method now returns a list of ``(success, docid, 154 | rev_or_exc)`` tuples. See the docstring of that method for the details. 155 | * ``schema.ListField`` proxy objects now support the ``__contains__()`` and 156 | ``index()`` methods (issue 77). 157 | * The results of the ``query()`` and ``view()`` methods in the ``schema.Document`` 158 | class are now properly wrapped in objects of the class if the ``include_docs`` 159 | option is set (issue 76). 160 | * Removed the ``eager`` option on the ``query()`` and ``view()`` methods of 161 | ``schema.Document``. Use the ``include_docs`` option instead, which doesn't 162 | require an additional request per document. 163 | * Added a ``copy()`` method to the ``client.Database`` class, which translates to 164 | a HTTP COPY request (issue 74). 165 | * Accessing a non-existing database through ``Server.__getitem__`` now throws 166 | a ``ResourceNotFound`` exception as advertised (issue 41). 167 | * Added a ``delete()`` method to the ``client.Server`` class for consistency 168 | (issue 64). 169 | * The ``couchdb-dump`` tool now operates in a streaming fashion, writing one 170 | document at a time to the resulting MIME multipart file (issue 58). 171 | * It is now possible to explicitly set the JSON module that should be used 172 | for decoding/encoding JSON data. The currently available choices are 173 | ``simplejson``, ``cjson``, and ``json`` (the standard library module). It is also 174 | possible to use custom decoding/encoding functions. 175 | * Add logging to the Python view server. It can now be configured to log to a 176 | given file or the standard error stream, and the log level can be set debug 177 | to see all communication between CouchDB and the view server (issue 55). 178 | 179 | 180 | Version 0.5 (Nov 29, 2008) 181 | -------------------------- 182 | 183 | * ``schema.Document`` objects can now be used in the documents list passed to 184 | ``client.Database.update()``. 185 | * ``Server.__contains__()`` and ``Database.__contains__()`` now use the HTTP HEAD 186 | method to avoid unnecessary transmission of data. ``Database.__del__()`` also 187 | uses HEAD to determine the latest revision of the document. 188 | * The ``Database`` class now has a method ``delete()`` that takes a document 189 | dictionary as parameter. This method should be used in preference to 190 | ``__del__`` as it allow conflict detection and handling. 191 | * Added ``cache`` and ``timeout`` arguments to the ``client.Server`` initializer. 192 | * The ``Database`` class now provides methods for deleting, retrieving, and 193 | updating attachments. 194 | * The Python view server now exposes a ``log()`` function to map and reduce 195 | functions (issue 21). 196 | * Handling of the rereduce stage in the Python view server has been fixed. 197 | * The ``Server`` and ``Database`` classes now implement the ``__nonzero__`` hook 198 | so that they produce sensible results in boolean conditions. 199 | * The client module will now reattempt a request that failed with a 200 | "connection reset by peer" error. 201 | * inf/nan values now raise a ``ValueError`` on the client side instead of 202 | triggering an internal server error (issue 31). 203 | * Added a new ``couchdb.design`` module that provides functionality for 204 | managing views in design documents, so that they can be defined in the 205 | Python application code, and the design documents actually stored in the 206 | database can be kept in sync with the definitions in the code. 207 | * The ``include_docs`` option for CouchDB views is now supported by the new 208 | ``doc`` property of row instances in view results. Thanks to Paul Davis for 209 | the patch (issue 33). 210 | * The ``keys`` option for views is now supported (issue 35). 211 | 212 | 213 | Version 0.4 (Jun 28, 2008) 214 | -------------------------- 215 | 216 | * Updated for compatibility with CouchDB 0.8.0 217 | * Added command-line scripts for importing/exporting databases. 218 | * The ``Database.update()`` function will now actually perform the ``POST`` 219 | request even when you do not iterate over the results (issue 5). 220 | * The ``_view`` prefix can now be omitted when specifying view names. 221 | 222 | 223 | Version 0.3 (Feb 6, 2008) 224 | ------------------------- 225 | 226 | * The ``schema.Document`` class now has a ``view()`` method that can be used to 227 | execute a CouchDB view and map the result rows back to objects of that 228 | schema. 229 | * The test suite now uses the new default port of CouchDB, 5984. 230 | * Views now return proxy objects to which you can apply slice syntax for 231 | "key", "startkey", and "endkey" filtering. 232 | * Add a ``query()`` classmethod to the ``Document`` class. 233 | 234 | 235 | Version 0.2 (Nov 21, 2007) 236 | -------------------------- 237 | 238 | * Added __len__ and __iter__ to the ``schema.Schema`` class to iterate 239 | over and get the number of items in a document or compound field. 240 | * The "version" property of client.Server now returns a plain string 241 | instead of a tuple of ints. 242 | * The client library now identifies itself with a meaningful 243 | User-Agent string. 244 | * ``schema.Document.store()`` now returns the document object instance, 245 | instead of just the document ID. 246 | * The string representation of ``schema.Document`` objects is now more 247 | comprehensive. 248 | * Only the view parameters "key", "startkey", and "endkey" are JSON 249 | encoded, anything else is left alone. 250 | * Slashes in document IDs are now URL-quoted until CouchDB supports 251 | them. 252 | * Allow the content-type to be passed for temp views via 253 | ``client.Database.query()`` so that view languages other than 254 | Javascript can be used. 255 | * Added ``client.Database.update()`` method to bulk insert/update 256 | documents in a database. 257 | * The view-server script wrapper has been renamed to ``couchpy``. 258 | * ``couchpy`` now supports ``--help`` and ``--version`` options. 259 | * Updated for compatibility with CouchDB release 0.7.0. 260 | 261 | 262 | Version 0.1 (Sep 23, 2007) 263 | -------------------------- 264 | 265 | * First public release. 266 | -------------------------------------------------------------------------------- /couchdb/mapping.py: -------------------------------------------------------------------------------- 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 | """Mapping from raw JSON data structures to Python objects and vice versa. 10 | 11 | >>> from couchdb import Server 12 | >>> server = Server() 13 | >>> db = server.create('python-tests') 14 | 15 | To define a document mapping, you declare a Python class inherited from 16 | `Document`, and add any number of `Field` attributes: 17 | 18 | >>> from datetime import datetime 19 | >>> from couchdb.mapping import Document, TextField, IntegerField, DateTimeField 20 | >>> class Person(Document): 21 | ... name = TextField() 22 | ... age = IntegerField() 23 | ... added = DateTimeField(default=datetime.now) 24 | >>> person = Person(name='John Doe', age=42) 25 | >>> person.store(db) #doctest: +ELLIPSIS 26 | 27 | >>> person.age 28 | 42 29 | 30 | You can then load the data from the CouchDB server through your `Document` 31 | subclass, and conveniently access all attributes: 32 | 33 | >>> person = Person.load(db, person.id) 34 | >>> old_rev = person.rev 35 | >>> person.name 36 | u'John Doe' 37 | >>> person.age 38 | 42 39 | >>> person.added #doctest: +ELLIPSIS 40 | datetime.datetime(...) 41 | 42 | To update a document, simply set the attributes, and then call the ``store()`` 43 | method: 44 | 45 | >>> person.name = 'John R. Doe' 46 | >>> person.store(db) #doctest: +ELLIPSIS 47 | 48 | 49 | If you retrieve the document from the server again, you should be getting the 50 | updated data: 51 | 52 | >>> person = Person.load(db, person.id) 53 | >>> person.name 54 | u'John R. Doe' 55 | >>> person.rev != old_rev 56 | True 57 | 58 | >>> del server['python-tests'] 59 | """ 60 | 61 | import copy 62 | 63 | from calendar import timegm 64 | from datetime import date, datetime, time 65 | from decimal import Decimal 66 | from time import strptime, struct_time 67 | 68 | from couchdb.design import ViewDefinition 69 | from couchdb import util 70 | 71 | __all__ = ['Mapping', 'Document', 'Field', 'TextField', 'FloatField', 72 | 'IntegerField', 'LongField', 'BooleanField', 'DecimalField', 73 | 'DateField', 'DateTimeField', 'TimeField', 'DictField', 'ListField', 74 | 'ViewField'] 75 | __docformat__ = 'restructuredtext en' 76 | 77 | DEFAULT = object() 78 | 79 | 80 | class Field(object): 81 | """Basic unit for mapping a piece of data between Python and JSON. 82 | 83 | Instances of this class can be added to subclasses of `Document` to describe 84 | the mapping of a document. 85 | """ 86 | 87 | def __init__(self, name=None, default=None): 88 | self.name = name 89 | self.default = default 90 | 91 | def __get__(self, instance, owner): 92 | if instance is None: 93 | return self 94 | value = instance._data.get(self.name) 95 | if value is not None: 96 | value = self._to_python(value) 97 | elif self.default is not None: 98 | default = self.default 99 | if callable(default): 100 | default = default() 101 | value = default 102 | return value 103 | 104 | def __set__(self, instance, value): 105 | if value is not None: 106 | value = self._to_json(value) 107 | instance._data[self.name] = value 108 | 109 | def _to_python(self, value): 110 | return util.utype(value) 111 | 112 | def _to_json(self, value): 113 | return self._to_python(value) 114 | 115 | 116 | class MappingMeta(type): 117 | 118 | def __new__(cls, name, bases, d): 119 | fields = {} 120 | for base in bases: 121 | if hasattr(base, '_fields'): 122 | fields.update(base._fields) 123 | for attrname, attrval in d.items(): 124 | if isinstance(attrval, Field): 125 | if not attrval.name: 126 | attrval.name = attrname 127 | fields[attrname] = attrval 128 | d['_fields'] = fields 129 | return type.__new__(cls, name, bases, d) 130 | 131 | MappingMetaClass = MappingMeta('MappingMetaClass', (object,), {}) 132 | 133 | 134 | class Mapping(MappingMetaClass): 135 | 136 | def __init__(self, **values): 137 | self._data = {} 138 | for attrname, field in self._fields.items(): 139 | if attrname in values: 140 | setattr(self, attrname, values.pop(attrname)) 141 | else: 142 | setattr(self, attrname, getattr(self, attrname)) 143 | 144 | def __iter__(self): 145 | return iter(self._data) 146 | 147 | def __len__(self): 148 | return len(self._data or ()) 149 | 150 | def __delitem__(self, name): 151 | del self._data[name] 152 | 153 | def __getitem__(self, name): 154 | return self._data[name] 155 | 156 | def __setitem__(self, name, value): 157 | self._data[name] = value 158 | 159 | def get(self, name, default=None): 160 | return self._data.get(name, default) 161 | 162 | def setdefault(self, name, default): 163 | return self._data.setdefault(name, default) 164 | 165 | def unwrap(self): 166 | return self._data 167 | 168 | @classmethod 169 | def build(cls, **d): 170 | fields = {} 171 | for attrname, attrval in d.items(): 172 | if not attrval.name: 173 | attrval.name = attrname 174 | fields[attrname] = attrval 175 | d['_fields'] = fields 176 | return type('AnonymousStruct', (cls,), d) 177 | 178 | @classmethod 179 | def wrap(cls, data): 180 | instance = cls() 181 | instance._data = data 182 | return instance 183 | 184 | def _to_python(self, value): 185 | return self.wrap(value) 186 | 187 | def _to_json(self, value): 188 | return self.unwrap() 189 | 190 | 191 | class ViewField(object): 192 | r"""Descriptor that can be used to bind a view definition to a property of 193 | a `Document` class. 194 | 195 | >>> class Person(Document): 196 | ... name = TextField() 197 | ... age = IntegerField() 198 | ... by_name = ViewField('people', '''\ 199 | ... function(doc) { 200 | ... emit(doc.name, doc); 201 | ... }''') 202 | >>> Person.by_name 203 | 204 | 205 | >>> print(Person.by_name.map_fun) 206 | function(doc) { 207 | emit(doc.name, doc); 208 | } 209 | 210 | That property can be used as a function, which will execute the view. 211 | 212 | >>> from couchdb import Database 213 | >>> db = Database('python-tests') 214 | 215 | >>> Person.by_name(db, count=3) 216 | {'count': 3}> 217 | 218 | The results produced by the view are automatically wrapped in the 219 | `Document` subclass the descriptor is bound to. In this example, it would 220 | return instances of the `Person` class. But please note that this requires 221 | the values of the view results to be dictionaries that can be mapped to the 222 | mapping defined by the containing `Document` class. Alternatively, the 223 | ``include_docs`` query option can be used to inline the actual documents in 224 | the view results, which will then be used instead of the values. 225 | 226 | If you use Python view functions, this class can also be used as a 227 | decorator: 228 | 229 | >>> class Person(Document): 230 | ... name = TextField() 231 | ... age = IntegerField() 232 | ... 233 | ... @ViewField.define('people') 234 | ... def by_name(doc): 235 | ... yield doc['name'], doc 236 | 237 | >>> Person.by_name 238 | 239 | 240 | >>> print(Person.by_name.map_fun) 241 | def by_name(doc): 242 | yield doc['name'], doc 243 | """ 244 | 245 | def __init__(self, design, map_fun, reduce_fun=None, name=None, 246 | language='javascript', wrapper=DEFAULT, **defaults): 247 | """Initialize the view descriptor. 248 | 249 | :param design: the name of the design document 250 | :param map_fun: the map function code 251 | :param reduce_fun: the reduce function code (optional) 252 | :param name: the actual name of the view in the design document, if 253 | it differs from the name the descriptor is assigned to 254 | :param language: the name of the language used 255 | :param wrapper: an optional callable that should be used to wrap the 256 | result rows 257 | :param defaults: default query string parameters to apply 258 | """ 259 | self.design = design 260 | self.name = name 261 | self.map_fun = map_fun 262 | self.reduce_fun = reduce_fun 263 | self.language = language 264 | self.wrapper = wrapper 265 | self.defaults = defaults 266 | 267 | @classmethod 268 | def define(cls, design, name=None, language='python', wrapper=DEFAULT, 269 | **defaults): 270 | """Factory method for use as a decorator (only suitable for Python 271 | view code). 272 | """ 273 | def view_wrapped(fun): 274 | return cls(design, fun, language=language, wrapper=wrapper, 275 | **defaults) 276 | return view_wrapped 277 | 278 | def __get__(self, instance, cls=None): 279 | if self.wrapper is DEFAULT: 280 | wrapper = cls._wrap_row 281 | else: 282 | wrapper = self.wrapper 283 | return ViewDefinition(self.design, self.name, self.map_fun, 284 | self.reduce_fun, language=self.language, 285 | wrapper=wrapper, **self.defaults) 286 | 287 | 288 | class DocumentMeta(MappingMeta): 289 | 290 | def __new__(cls, name, bases, d): 291 | for attrname, attrval in d.items(): 292 | if isinstance(attrval, ViewField): 293 | if not attrval.name: 294 | attrval.name = attrname 295 | return MappingMeta.__new__(cls, name, bases, d) 296 | 297 | DocumentMetaClass = DocumentMeta('DocumentMetaClass', (object,), {}) 298 | 299 | 300 | class Document(DocumentMetaClass, Mapping): 301 | 302 | def __init__(self, id=None, **values): 303 | Mapping.__init__(self, **values) 304 | if id is not None: 305 | self.id = id 306 | 307 | def __repr__(self): 308 | return '<%s %r@%r %r>' % (type(self).__name__, self.id, self.rev, 309 | dict([(k, v) for k, v in self._data.items() 310 | if k not in ('_id', '_rev')])) 311 | 312 | def _get_id(self): 313 | if hasattr(self._data, 'id'): # When data is client.Document 314 | return self._data.id 315 | return self._data.get('_id') 316 | def _set_id(self, value): 317 | if self.id is not None: 318 | raise AttributeError('id can only be set on new documents') 319 | self._data['_id'] = value 320 | id = property(_get_id, _set_id, doc='The document ID') 321 | 322 | @property 323 | def rev(self): 324 | """The document revision. 325 | 326 | :rtype: basestring 327 | """ 328 | if hasattr(self._data, 'rev'): # When data is client.Document 329 | return self._data.rev 330 | return self._data.get('_rev') 331 | 332 | def items(self): 333 | """Return the fields as a list of ``(name, value)`` tuples. 334 | 335 | This method is provided to enable easy conversion to native dictionary 336 | objects, for example to allow use of `mapping.Document` instances with 337 | `client.Database.update`. 338 | 339 | >>> class Post(Document): 340 | ... title = TextField() 341 | ... author = TextField() 342 | >>> post = Post(id='foo-bar', title='Foo bar', author='Joe') 343 | >>> sorted(post.items()) 344 | [('_id', 'foo-bar'), ('author', u'Joe'), ('title', u'Foo bar')] 345 | 346 | :return: a list of ``(name, value)`` tuples 347 | """ 348 | retval = [] 349 | if self.id is not None: 350 | retval.append(('_id', self.id)) 351 | if self.rev is not None: 352 | retval.append(('_rev', self.rev)) 353 | for name, value in self._data.items(): 354 | if name not in ('_id', '_rev'): 355 | retval.append((name, value)) 356 | return retval 357 | 358 | @classmethod 359 | def load(cls, db, id): 360 | """Load a specific document from the given database. 361 | 362 | :param db: the `Database` object to retrieve the document from 363 | :param id: the document ID 364 | :return: the `Document` instance, or `None` if no document with the 365 | given ID was found 366 | """ 367 | doc = db.get(id) 368 | if doc is None: 369 | return None 370 | return cls.wrap(doc) 371 | 372 | def store(self, db): 373 | """Store the document in the given database.""" 374 | db.save(self._data) 375 | return self 376 | 377 | @classmethod 378 | def query(cls, db, map_fun, reduce_fun, language='javascript', **options): 379 | """Execute a CouchDB temporary view and map the result values back to 380 | objects of this mapping. 381 | 382 | Note that by default, any properties of the document that are not 383 | included in the values of the view will be treated as if they were 384 | missing from the document. If you want to load the full document for 385 | every row, set the ``include_docs`` option to ``True``. 386 | """ 387 | return db.query(map_fun, reduce_fun=reduce_fun, language=language, 388 | wrapper=cls._wrap_row, **options) 389 | 390 | @classmethod 391 | def view(cls, db, viewname, **options): 392 | """Execute a CouchDB named view and map the result values back to 393 | objects of this mapping. 394 | 395 | Note that by default, any properties of the document that are not 396 | included in the values of the view will be treated as if they were 397 | missing from the document. If you want to load the full document for 398 | every row, set the ``include_docs`` option to ``True``. 399 | """ 400 | return db.view(viewname, wrapper=cls._wrap_row, **options) 401 | 402 | @classmethod 403 | def _wrap_row(cls, row): 404 | doc = row.get('doc') 405 | if doc is not None: 406 | return cls.wrap(doc) 407 | data = row['value'] 408 | data['_id'] = row['id'] 409 | if 'rev' in data: # When data is client.Document 410 | data['_rev'] = data['rev'] 411 | return cls.wrap(data) 412 | 413 | 414 | class TextField(Field): 415 | """Mapping field for string values.""" 416 | _to_python = util.utype 417 | 418 | 419 | class FloatField(Field): 420 | """Mapping field for float values.""" 421 | _to_python = float 422 | 423 | 424 | class IntegerField(Field): 425 | """Mapping field for integer values.""" 426 | _to_python = int 427 | 428 | 429 | class LongField(Field): 430 | """Mapping field for long integer values.""" 431 | _to_python = util.ltype 432 | 433 | 434 | class BooleanField(Field): 435 | """Mapping field for boolean values.""" 436 | _to_python = bool 437 | 438 | 439 | class DecimalField(Field): 440 | """Mapping field for decimal values.""" 441 | 442 | def _to_python(self, value): 443 | return Decimal(value) 444 | 445 | def _to_json(self, value): 446 | return util.utype(value) 447 | 448 | 449 | class DateField(Field): 450 | """Mapping field for storing dates. 451 | 452 | >>> field = DateField() 453 | >>> field._to_python('2007-04-01') 454 | datetime.date(2007, 4, 1) 455 | >>> field._to_json(date(2007, 4, 1)) 456 | '2007-04-01' 457 | >>> field._to_json(datetime(2007, 4, 1, 15, 30)) 458 | '2007-04-01' 459 | """ 460 | 461 | def _to_python(self, value): 462 | if isinstance(value, util.strbase): 463 | try: 464 | value = date(*strptime(value, '%Y-%m-%d')[:3]) 465 | except ValueError: 466 | raise ValueError('Invalid ISO date %r' % value) 467 | return value 468 | 469 | def _to_json(self, value): 470 | if isinstance(value, datetime): 471 | value = value.date() 472 | return value.isoformat() 473 | 474 | 475 | class DateTimeField(Field): 476 | """Mapping field for storing date/time values. 477 | 478 | >>> field = DateTimeField() 479 | >>> field._to_python('2007-04-01T15:30:00Z') 480 | datetime.datetime(2007, 4, 1, 15, 30) 481 | >>> field._to_python('2007-04-01T15:30:00.009876Z') 482 | datetime.datetime(2007, 4, 1, 15, 30, 0, 9876) 483 | >>> field._to_json(datetime(2007, 4, 1, 15, 30, 0)) 484 | '2007-04-01T15:30:00Z' 485 | >>> field._to_json(datetime(2007, 4, 1, 15, 30, 0, 9876)) 486 | '2007-04-01T15:30:00.009876Z' 487 | >>> field._to_json(date(2007, 4, 1)) 488 | '2007-04-01T00:00:00Z' 489 | """ 490 | 491 | def _to_python(self, value): 492 | if isinstance(value, util.strbase): 493 | try: 494 | split_value = value.split('.') # strip out microseconds 495 | if len(split_value) == 1: # No microseconds provided 496 | value = split_value[0] 497 | value = value.rstrip('Z') #remove timezone separator 498 | value = datetime.strptime(value, '%Y-%m-%dT%H:%M:%S') 499 | else: 500 | value = value.rstrip('Z') 501 | value = datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f') 502 | 503 | except ValueError: 504 | raise ValueError('Invalid ISO date/time %r' % value) 505 | return value 506 | 507 | def _to_json(self, value): 508 | if isinstance(value, struct_time): 509 | value = datetime.utcfromtimestamp(timegm(value)) 510 | elif not isinstance(value, datetime): 511 | value = datetime.combine(value, time(0)) 512 | return value.isoformat() + 'Z' 513 | 514 | 515 | class TimeField(Field): 516 | """Mapping field for storing times. 517 | 518 | >>> field = TimeField() 519 | >>> field._to_python('15:30:00') 520 | datetime.time(15, 30) 521 | >>> field._to_json(time(15, 30)) 522 | '15:30:00' 523 | >>> field._to_json(datetime(2007, 4, 1, 15, 30)) 524 | '15:30:00' 525 | """ 526 | 527 | def _to_python(self, value): 528 | if isinstance(value, util.strbase): 529 | try: 530 | value = value.split('.', 1)[0] # strip out microseconds 531 | value = time(*strptime(value, '%H:%M:%S')[3:6]) 532 | except ValueError: 533 | raise ValueError('Invalid ISO time %r' % value) 534 | return value 535 | 536 | def _to_json(self, value): 537 | if isinstance(value, datetime): 538 | value = value.time() 539 | return value.replace(microsecond=0).isoformat() 540 | 541 | 542 | class DictField(Field): 543 | """Field type for nested dictionaries. 544 | 545 | >>> from couchdb import Server 546 | >>> server = Server() 547 | >>> db = server.create('python-tests') 548 | 549 | >>> class Post(Document): 550 | ... title = TextField() 551 | ... content = TextField() 552 | ... author = DictField(Mapping.build( 553 | ... name = TextField(), 554 | ... email = TextField() 555 | ... )) 556 | ... extra = DictField() 557 | 558 | >>> post = Post( 559 | ... title='Foo bar', 560 | ... author=dict(name='John Doe', 561 | ... email='john@doe.com'), 562 | ... extra=dict(foo='bar'), 563 | ... ) 564 | >>> post.store(db) #doctest: +ELLIPSIS 565 | 566 | >>> post = Post.load(db, post.id) 567 | >>> post.author.name 568 | u'John Doe' 569 | >>> post.author.email 570 | u'john@doe.com' 571 | >>> post.extra 572 | {u'foo': u'bar'} 573 | 574 | >>> del server['python-tests'] 575 | """ 576 | def __init__(self, mapping=None, name=None, default=None): 577 | default = default or {} 578 | Field.__init__(self, name=name, default=lambda: default.copy()) 579 | self.mapping = mapping 580 | 581 | def _to_python(self, value): 582 | if self.mapping is None: 583 | return value 584 | else: 585 | return self.mapping.wrap(value) 586 | 587 | def _to_json(self, value): 588 | if self.mapping is None: 589 | return value 590 | if not isinstance(value, Mapping): 591 | value = self.mapping(**value) 592 | return value.unwrap() 593 | 594 | 595 | class ListField(Field): 596 | """Field type for sequences of other fields. 597 | 598 | >>> from couchdb import Server 599 | >>> server = Server() 600 | >>> db = server.create('python-tests') 601 | 602 | >>> class Post(Document): 603 | ... title = TextField() 604 | ... content = TextField() 605 | ... pubdate = DateTimeField(default=datetime.now) 606 | ... comments = ListField(DictField(Mapping.build( 607 | ... author = TextField(), 608 | ... content = TextField(), 609 | ... time = DateTimeField() 610 | ... ))) 611 | 612 | >>> post = Post(title='Foo bar') 613 | >>> post.comments.append(author='myself', content='Bla bla', 614 | ... time=datetime.now()) 615 | >>> len(post.comments) 616 | 1 617 | >>> post.store(db) #doctest: +ELLIPSIS 618 | 619 | >>> post = Post.load(db, post.id) 620 | >>> comment = post.comments[0] 621 | >>> comment['author'] 622 | u'myself' 623 | >>> comment['content'] 624 | u'Bla bla' 625 | >>> comment['time'] #doctest: +ELLIPSIS 626 | u'...T...Z' 627 | 628 | >>> del server['python-tests'] 629 | """ 630 | 631 | def __init__(self, field, name=None, default=None): 632 | default = default or [] 633 | Field.__init__(self, name=name, default=lambda: copy.copy(default)) 634 | if type(field) is type: 635 | if issubclass(field, Field): 636 | field = field() 637 | elif issubclass(field, Mapping): 638 | field = DictField(field) 639 | self.field = field 640 | 641 | def _to_python(self, value): 642 | return self.Proxy(value, self.field) 643 | 644 | def _to_json(self, value): 645 | return [self.field._to_json(item) for item in value] 646 | 647 | 648 | class Proxy(list): 649 | 650 | def __init__(self, list, field): 651 | self.list = list 652 | self.field = field 653 | 654 | def __lt__(self, other): 655 | return self.list < other 656 | 657 | def __le__(self, other): 658 | return self.list <= other 659 | 660 | def __eq__(self, other): 661 | return self.list == other 662 | 663 | def __ne__(self, other): 664 | return self.list != other 665 | 666 | def __gt__(self, other): 667 | return self.list > other 668 | 669 | def __ge__(self, other): 670 | return self.list >= other 671 | 672 | def __repr__(self): 673 | return repr(self.list) 674 | 675 | def __str__(self): 676 | return str(self.list) 677 | 678 | def __unicode__(self): 679 | return util.utype(self.list) 680 | 681 | def __delitem__(self, index): 682 | if isinstance(index, slice): 683 | self.__delslice__(index.start, index.stop) 684 | else: 685 | del self.list[index] 686 | 687 | def __getitem__(self, index): 688 | if isinstance(index, slice): 689 | return self.__getslice__(index.start, index.stop) 690 | return self.field._to_python(self.list[index]) 691 | 692 | def __setitem__(self, index, value): 693 | if isinstance(index, slice): 694 | self.__setslice__(index.start, index.stop, value) 695 | else: 696 | self.list[index] = self.field._to_json(value) 697 | 698 | def __delslice__(self, i, j): 699 | del self.list[i:j] 700 | 701 | def __getslice__(self, i, j): 702 | return ListField.Proxy(self.list[i:j], self.field) 703 | 704 | def __setslice__(self, i, j, seq): 705 | self.list[i:j] = (self.field._to_json(v) for v in seq) 706 | 707 | def __contains__(self, value): 708 | for item in self.list: 709 | if self.field._to_python(item) == value: 710 | return True 711 | return False 712 | 713 | def __iter__(self): 714 | for index in range(len(self)): 715 | yield self[index] 716 | 717 | def __len__(self): 718 | return len(self.list) 719 | 720 | def __nonzero__(self): 721 | return bool(self.list) 722 | 723 | def append(self, *args, **kwargs): 724 | if args or not isinstance(self.field, DictField): 725 | if len(args) != 1: 726 | raise TypeError('append() takes exactly one argument ' 727 | '(%s given)' % len(args)) 728 | value = args[0] 729 | else: 730 | value = kwargs 731 | self.list.append(self.field._to_json(value)) 732 | 733 | def count(self, value): 734 | return [i for i in self].count(value) 735 | 736 | def extend(self, list): 737 | for item in list: 738 | self.append(item) 739 | 740 | def index(self, value): 741 | return self.list.index(self.field._to_json(value)) 742 | 743 | def insert(self, idx, *args, **kwargs): 744 | if args or not isinstance(self.field, DictField): 745 | if len(args) != 1: 746 | raise TypeError('insert() takes exactly 2 arguments ' 747 | '(%s given)' % len(args)) 748 | value = args[0] 749 | else: 750 | value = kwargs 751 | self.list.insert(idx, self.field._to_json(value)) 752 | 753 | def remove(self, value): 754 | return self.list.remove(self.field._to_json(value)) 755 | 756 | def pop(self, *args): 757 | return self.field._to_python(self.list.pop(*args)) 758 | -------------------------------------------------------------------------------- /couchdb/http.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright (C) 2009 Christopher Lenz 5 | # All rights reserved. 6 | # 7 | # This software is licensed as described in the file COPYING, which 8 | # you should have received as part of this distribution. 9 | 10 | """Simple HTTP client implementation based on the ``httplib`` module in the 11 | standard library. 12 | """ 13 | 14 | from base64 import b64encode 15 | from datetime import datetime 16 | import errno 17 | import socket 18 | import time 19 | import sys 20 | import ssl 21 | 22 | try: 23 | from threading import Lock 24 | except ImportError: 25 | from dummy_threading import Lock 26 | 27 | try: 28 | from http.client import BadStatusLine, HTTPConnection, HTTPSConnection 29 | except ImportError: 30 | from httplib import BadStatusLine, HTTPConnection, HTTPSConnection 31 | 32 | try: 33 | from email.Utils import parsedate 34 | except ImportError: 35 | from email.utils import parsedate 36 | 37 | from couchdb import json 38 | from couchdb import util 39 | 40 | __all__ = ['HTTPError', 'PreconditionFailed', 'ResourceNotFound', 41 | 'ResourceConflict', 'ServerError', 'Unauthorized', 'Forbidden', 42 | 'RedirectLimit', 'Session', 'Resource'] 43 | __docformat__ = 'restructuredtext en' 44 | 45 | 46 | if sys.version < '2.7': 47 | 48 | from httplib import CannotSendHeader, _CS_REQ_STARTED, _CS_REQ_SENT 49 | 50 | class NagleMixin: 51 | """ 52 | Mixin to upgrade httplib connection types so headers and body can be 53 | sent at the same time to avoid triggering Nagle's algorithm. 54 | 55 | Based on code originally copied from Python 2.7's httplib module. 56 | """ 57 | 58 | def endheaders(self, message_body=None): 59 | if self.__dict__['_HTTPConnection__state'] == _CS_REQ_STARTED: 60 | self.__dict__['_HTTPConnection__state'] = _CS_REQ_SENT 61 | else: 62 | raise CannotSendHeader() 63 | self._send_output(message_body) 64 | 65 | def _send_output(self, message_body=None): 66 | self._buffer.extend(("", "")) 67 | msg = "\r\n".join(self._buffer) 68 | del self._buffer[:] 69 | if isinstance(message_body, str): 70 | msg += message_body 71 | message_body = None 72 | self.send(msg) 73 | if message_body is not None: 74 | self.send(message_body) 75 | 76 | class HTTPConnection(NagleMixin, HTTPConnection): 77 | pass 78 | 79 | class HTTPSConnection(NagleMixin, HTTPSConnection): 80 | pass 81 | 82 | 83 | class HTTPError(Exception): 84 | """Base class for errors based on HTTP status codes >= 400.""" 85 | 86 | 87 | class PreconditionFailed(HTTPError): 88 | """Exception raised when a 412 HTTP error is received in response to a 89 | request. 90 | """ 91 | 92 | 93 | class ResourceNotFound(HTTPError): 94 | """Exception raised when a 404 HTTP error is received in response to a 95 | request. 96 | """ 97 | 98 | 99 | class ResourceConflict(HTTPError): 100 | """Exception raised when a 409 HTTP error is received in response to a 101 | request. 102 | """ 103 | 104 | 105 | class ServerError(HTTPError): 106 | """Exception raised when an unexpected HTTP error is received in response 107 | to a request. 108 | """ 109 | 110 | 111 | class Unauthorized(HTTPError): 112 | """Exception raised when the server requires authentication credentials 113 | but either none are provided, or they are incorrect. 114 | """ 115 | 116 | 117 | class Forbidden(HTTPError): 118 | """Exception raised when the request requires an authorisation that the 119 | current user does not have. 120 | """ 121 | 122 | 123 | class RedirectLimit(Exception): 124 | """Exception raised when a request is redirected more often than allowed 125 | by the maximum number of redirections. 126 | """ 127 | 128 | 129 | CHUNK_SIZE = 1024 * 8 130 | 131 | class ResponseBody(object): 132 | 133 | def __init__(self, resp, conn_pool, url, conn): 134 | self.resp = resp 135 | self.chunked = self.resp.msg.get('transfer-encoding') == 'chunked' 136 | self.conn_pool = conn_pool 137 | self.url = url 138 | self.conn = conn 139 | 140 | def __del__(self): 141 | if not self.chunked: 142 | self.close() 143 | else: 144 | self.resp.close() 145 | if self.conn: 146 | # Since chunked responses can be infinite (i.e. for 147 | # feed=continuous), and we want to avoid leaking sockets 148 | # (even if just to prevent ResourceWarnings when running 149 | # the test suite on Python 3), we'll close this connection 150 | # eagerly. We can't get it into the clean state required to 151 | # put it back into the ConnectionPool (since we don't know 152 | # when it ends and we can only do blocking reads). Finding 153 | # out whether it might in fact end would be relatively onerous 154 | # and require a layering violation. 155 | self.conn.close() 156 | 157 | def read(self, size=None): 158 | bytes = self.resp.read(size) 159 | if size is None or len(bytes) < size: 160 | self.close() 161 | return bytes 162 | 163 | def _release_conn(self): 164 | self.conn_pool.release(self.url, self.conn) 165 | self.conn_pool, self.url, self.conn = None, None, None 166 | 167 | def close(self): 168 | while not self.resp.isclosed(): 169 | chunk = self.resp.read(CHUNK_SIZE) 170 | if not chunk: 171 | self.resp.close() 172 | if self.conn: 173 | self._release_conn() 174 | 175 | def iterchunks(self): 176 | assert self.chunked 177 | buffer = [] 178 | while True: 179 | 180 | if self.resp.isclosed(): 181 | break 182 | 183 | chunksz = int(self.resp.fp.readline().strip(), 16) 184 | if not chunksz: 185 | self.resp.fp.read(2) #crlf 186 | self.resp.close() 187 | self._release_conn() 188 | break 189 | 190 | chunk = self.resp.fp.read(chunksz) 191 | for ln in chunk.splitlines(True): 192 | 193 | end = ln == b'\n' and not buffer # end of response 194 | if not ln or end: 195 | break 196 | 197 | buffer.append(ln) 198 | if ln.endswith(b'\n'): 199 | yield b''.join(buffer) 200 | buffer = [] 201 | 202 | self.resp.fp.read(2) #crlf 203 | 204 | 205 | RETRYABLE_ERRORS = frozenset([ 206 | errno.EPIPE, errno.ETIMEDOUT, 207 | errno.ECONNRESET, errno.ECONNREFUSED, errno.ECONNABORTED, 208 | errno.EHOSTDOWN, errno.EHOSTUNREACH, 209 | errno.ENETRESET, errno.ENETUNREACH, errno.ENETDOWN 210 | ]) 211 | 212 | 213 | class Session(object): 214 | 215 | def __init__(self, cache=None, timeout=None, max_redirects=5, 216 | retry_delays=[0], retryable_errors=RETRYABLE_ERRORS): 217 | """Initialize an HTTP client session. 218 | 219 | :param cache: an instance with a dict-like interface or None to allow 220 | Session to create a dict for caching. 221 | :param timeout: socket timeout in number of seconds, or `None` for no 222 | timeout (the default) 223 | :param retry_delays: list of request retry delays. 224 | """ 225 | from couchdb import __version__ as VERSION 226 | self.user_agent = 'CouchDB-Python/%s' % VERSION 227 | # XXX We accept a `cache` dict arg, but the ref gets overwritten later 228 | # during cache cleanup. Do we remove the cache arg (does using a shared 229 | # Session instance cover the same use cases?) or fix the cache cleanup? 230 | # For now, let's just assign the dict to the Cache instance to retain 231 | # current behaviour. 232 | if cache is not None: 233 | cache_by_url = cache 234 | cache = Cache() 235 | cache.by_url = cache_by_url 236 | else: 237 | cache = Cache() 238 | self.cache = cache 239 | self.max_redirects = max_redirects 240 | self.perm_redirects = {} 241 | 242 | self._disable_ssl_verification = False 243 | self._timeout = timeout 244 | self.connection_pool = ConnectionPool( 245 | self._timeout, 246 | disable_ssl_verification=self._disable_ssl_verification) 247 | 248 | self.retry_delays = list(retry_delays) # We don't want this changing on us. 249 | self.retryable_errors = set(retryable_errors) 250 | 251 | def disable_ssl_verification(self): 252 | """Disable verification of SSL certificates and re-initialize the 253 | ConnectionPool. Only applicable on Python 2.7.9+ as previous versions 254 | of Python don't verify SSL certs.""" 255 | self._disable_ssl_verification = True 256 | self.connection_pool = ConnectionPool(self._timeout, 257 | disable_ssl_verification=self._disable_ssl_verification) 258 | 259 | def request(self, method, url, body=None, headers=None, credentials=None, 260 | num_redirects=0): 261 | if url in self.perm_redirects: 262 | url = self.perm_redirects[url] 263 | method = method.upper() 264 | 265 | if headers is None: 266 | headers = {} 267 | headers.setdefault('Accept', 'application/json') 268 | headers['User-Agent'] = self.user_agent 269 | 270 | cached_resp = None 271 | if method in ('GET', 'HEAD'): 272 | cached_resp = self.cache.get(url) 273 | if cached_resp is not None: 274 | etag = cached_resp[1].get('etag') 275 | if etag: 276 | headers['If-None-Match'] = etag 277 | 278 | if (body is not None and not isinstance(body, util.strbase) and 279 | not hasattr(body, 'read')): 280 | body = json.encode(body).encode('utf-8') 281 | headers.setdefault('Content-Type', 'application/json') 282 | 283 | if body is None: 284 | headers.setdefault('Content-Length', '0') 285 | elif isinstance(body, util.strbase): 286 | headers.setdefault('Content-Length', str(len(body))) 287 | else: 288 | headers['Transfer-Encoding'] = 'chunked' 289 | 290 | authorization = basic_auth(credentials) 291 | if authorization: 292 | headers['Authorization'] = authorization 293 | 294 | path_query = util.urlunsplit(('', '') + util.urlsplit(url)[2:4] + ('',)) 295 | conn = self.connection_pool.get(url) 296 | 297 | def _try_request_with_retries(retries): 298 | while True: 299 | try: 300 | return _try_request() 301 | except socket.error as e: 302 | ecode = e.args[0] 303 | if ecode not in self.retryable_errors: 304 | raise 305 | try: 306 | delay = next(retries) 307 | except StopIteration: 308 | # No more retries, raise last socket error. 309 | raise e 310 | finally: 311 | time.sleep(delay) 312 | conn.close() 313 | 314 | def _try_request(): 315 | try: 316 | conn.putrequest(method, path_query, skip_accept_encoding=True) 317 | for header in headers: 318 | conn.putheader(header, headers[header]) 319 | if body is None: 320 | conn.endheaders() 321 | else: 322 | if isinstance(body, util.strbase): 323 | if isinstance(body, util.utype): 324 | conn.endheaders(body.encode('utf-8')) 325 | else: 326 | conn.endheaders(body) 327 | else: # assume a file-like object and send in chunks 328 | conn.endheaders() 329 | while 1: 330 | chunk = body.read(CHUNK_SIZE) 331 | if not chunk: 332 | break 333 | if isinstance(chunk, util.utype): 334 | chunk = chunk.encode('utf-8') 335 | status = ('%x\r\n' % len(chunk)).encode('utf-8') 336 | conn.send(status + chunk + b'\r\n') 337 | conn.send(b'0\r\n\r\n') 338 | return conn.getresponse() 339 | except BadStatusLine as e: 340 | # httplib raises a BadStatusLine when it cannot read the status 341 | # line saying, "Presumably, the server closed the connection 342 | # before sending a valid response." 343 | # Raise as ECONNRESET to simplify retry logic. 344 | if e.line == '' or e.line == "''": 345 | raise socket.error(errno.ECONNRESET) 346 | else: 347 | raise 348 | 349 | resp = _try_request_with_retries(iter(self.retry_delays)) 350 | status = resp.status 351 | 352 | # Handle conditional response 353 | if status == 304 and method in ('GET', 'HEAD'): 354 | resp.read() 355 | self.connection_pool.release(url, conn) 356 | status, msg, data = cached_resp 357 | if data is not None: 358 | data = util.StringIO(data) 359 | return status, msg, data 360 | elif cached_resp: 361 | self.cache.remove(url) 362 | 363 | # Handle redirects 364 | if status == 303 or \ 365 | method in ('GET', 'HEAD') and status in (301, 302, 307): 366 | resp.read() 367 | self.connection_pool.release(url, conn) 368 | if num_redirects > self.max_redirects: 369 | raise RedirectLimit('Redirection limit exceeded') 370 | location = resp.getheader('location') 371 | 372 | # in case of relative location: add scheme and host to the location 373 | location_split = util.urlsplit(location) 374 | 375 | if not location_split[0]: 376 | orig_url_split = util.urlsplit(url) 377 | location = util.urlunsplit(orig_url_split[:2] + location_split[2:]) 378 | 379 | if status == 301: 380 | self.perm_redirects[url] = location 381 | elif status == 303: 382 | method = 'GET' 383 | return self.request(method, location, body, headers, 384 | num_redirects=num_redirects + 1) 385 | 386 | data = None 387 | streamed = False 388 | 389 | # Read the full response for empty responses so that the connection is 390 | # in good state for the next request 391 | if method == 'HEAD' or resp.getheader('content-length') == '0' or \ 392 | status < 200 or status in (204, 304): 393 | resp.read() 394 | self.connection_pool.release(url, conn) 395 | 396 | # Buffer small non-JSON response bodies 397 | elif int(resp.getheader('content-length', sys.maxsize)) < CHUNK_SIZE: 398 | data = resp.read() 399 | self.connection_pool.release(url, conn) 400 | 401 | # For large or chunked response bodies, do not buffer the full body, 402 | # and instead return a minimal file-like object 403 | else: 404 | data = ResponseBody(resp, self.connection_pool, url, conn) 405 | streamed = True 406 | 407 | # Handle errors 408 | if status >= 400: 409 | ctype = resp.getheader('content-type') 410 | if data is not None and 'application/json' in ctype: 411 | data = json.decode(data.decode('utf-8')) 412 | error = data.get('error'), data.get('reason') 413 | elif method != 'HEAD': 414 | error = resp.read() 415 | self.connection_pool.release(url, conn) 416 | else: 417 | error = '' 418 | if status == 401: 419 | raise Unauthorized(error) 420 | elif status == 403: 421 | raise Forbidden(error) 422 | elif status == 404: 423 | raise ResourceNotFound(error) 424 | elif status == 409: 425 | raise ResourceConflict(error) 426 | elif status == 412: 427 | raise PreconditionFailed(error) 428 | else: 429 | raise ServerError((status, error)) 430 | 431 | # Store cachable responses 432 | if not streamed and method == 'GET' and 'etag' in resp.msg: 433 | self.cache.put(url, (status, resp.msg, data)) 434 | 435 | if not streamed and data is not None: 436 | data = util.StringIO(data) 437 | 438 | return status, resp.msg, data 439 | 440 | 441 | def cache_sort(i): 442 | return datetime.fromtimestamp(time.mktime(parsedate(i[1][1]['Date']))) 443 | 444 | class Cache(object): 445 | """Content cache.""" 446 | 447 | # Some random values to limit memory use 448 | keep_size, max_size = 10, 75 449 | 450 | def __init__(self): 451 | self.by_url = {} 452 | 453 | def get(self, url): 454 | return self.by_url.get(url) 455 | 456 | def put(self, url, response): 457 | self.by_url[url] = response 458 | if len(self.by_url) > self.max_size: 459 | self._clean() 460 | 461 | def remove(self, url): 462 | self.by_url.pop(url, None) 463 | 464 | def _clean(self): 465 | ls = sorted(self.by_url.items(), key=cache_sort) 466 | self.by_url = dict(ls[-self.keep_size:]) 467 | 468 | 469 | class InsecureHTTPSConnection(HTTPSConnection): 470 | """Wrapper class to create an HTTPSConnection without SSL verification 471 | (the default behavior in Python < 2.7.9). See: 472 | https://docs.python.org/2/library/httplib.html#httplib.HTTPSConnection""" 473 | if sys.version_info >= (2, 7, 9): 474 | def __init__(self, *a, **k): 475 | k['context'] = ssl._create_unverified_context() 476 | HTTPSConnection.__init__(self, *a, **k) 477 | 478 | 479 | class ConnectionPool(object): 480 | """HTTP connection pool.""" 481 | 482 | def __init__(self, timeout, disable_ssl_verification=False): 483 | self.timeout = timeout 484 | self.disable_ssl_verification = disable_ssl_verification 485 | self.conns = {} # HTTP connections keyed by (scheme, host) 486 | self.lock = Lock() 487 | 488 | def get(self, url): 489 | 490 | scheme, host = util.urlsplit(url, 'http', False)[:2] 491 | 492 | # Try to reuse an existing connection. 493 | self.lock.acquire() 494 | try: 495 | conns = self.conns.setdefault((scheme, host), []) 496 | if conns: 497 | conn = conns.pop(-1) 498 | else: 499 | conn = None 500 | finally: 501 | self.lock.release() 502 | 503 | # Create a new connection if nothing was available. 504 | if conn is None: 505 | if scheme == 'http': 506 | cls = HTTPConnection 507 | elif scheme == 'https': 508 | if self.disable_ssl_verification: 509 | cls = InsecureHTTPSConnection 510 | else: 511 | cls = HTTPSConnection 512 | else: 513 | raise ValueError('%s is not a supported scheme' % scheme) 514 | conn = cls(host, timeout=self.timeout) 515 | conn.connect() 516 | 517 | return conn 518 | 519 | def release(self, url, conn): 520 | scheme, host = util.urlsplit(url, 'http', False)[:2] 521 | self.lock.acquire() 522 | try: 523 | self.conns.setdefault((scheme, host), []).append(conn) 524 | finally: 525 | self.lock.release() 526 | 527 | def __del__(self): 528 | for key, conns in list(self.conns.items()): 529 | for conn in conns: 530 | conn.close() 531 | 532 | 533 | class Resource(object): 534 | 535 | def __init__(self, url, session, headers=None): 536 | if sys.version_info[0] == 2 and isinstance(url, util.utype): 537 | url = url.encode('utf-8') # kind of an ugly hack for issue 235 538 | self.url, self.credentials = extract_credentials(url) 539 | if session is None: 540 | session = Session() 541 | self.session = session 542 | self.headers = headers or {} 543 | 544 | def __call__(self, *path): 545 | obj = type(self)(urljoin(self.url, *path), self.session) 546 | obj.credentials = self.credentials 547 | obj.headers = self.headers.copy() 548 | return obj 549 | 550 | def delete(self, path=None, headers=None, **params): 551 | return self._request('DELETE', path, headers=headers, **params) 552 | 553 | def get(self, path=None, headers=None, **params): 554 | return self._request('GET', path, headers=headers, **params) 555 | 556 | def head(self, path=None, headers=None, **params): 557 | return self._request('HEAD', path, headers=headers, **params) 558 | 559 | def post(self, path=None, body=None, headers=None, **params): 560 | return self._request('POST', path, body=body, headers=headers, 561 | **params) 562 | 563 | def put(self, path=None, body=None, headers=None, **params): 564 | return self._request('PUT', path, body=body, headers=headers, **params) 565 | 566 | def delete_json(self, path=None, headers=None, **params): 567 | return self._request_json('DELETE', path, headers=headers, **params) 568 | 569 | def get_json(self, path=None, headers=None, **params): 570 | return self._request_json('GET', path, headers=headers, **params) 571 | 572 | def post_json(self, path=None, body=None, headers=None, **params): 573 | return self._request_json('POST', path, body=body, headers=headers, 574 | **params) 575 | 576 | def put_json(self, path=None, body=None, headers=None, **params): 577 | return self._request_json('PUT', path, body=body, headers=headers, 578 | **params) 579 | 580 | def _request(self, method, path=None, body=None, headers=None, **params): 581 | all_headers = self.headers.copy() 582 | all_headers.update(headers or {}) 583 | if path is not None: 584 | if isinstance(path, list): 585 | url = urljoin(self.url, *path, **params) 586 | else: 587 | url = urljoin(self.url, path, **params) 588 | else: 589 | url = urljoin(self.url, **params) 590 | return self.session.request(method, url, body=body, 591 | headers=all_headers, 592 | credentials=self.credentials) 593 | 594 | def _request_json(self, method, path=None, body=None, headers=None, **params): 595 | status, headers, data = self._request(method, path, body=body, 596 | headers=headers, **params) 597 | if 'application/json' in headers.get('content-type', ''): 598 | data = json.decode(data.read().decode('utf-8')) 599 | return status, headers, data 600 | 601 | 602 | 603 | def extract_credentials(url): 604 | """Extract authentication (user name and password) credentials from the 605 | given URL. 606 | 607 | >>> extract_credentials('http://localhost:5984/_config/') 608 | ('http://localhost:5984/_config/', None) 609 | >>> extract_credentials('http://joe:secret@localhost:5984/_config/') 610 | ('http://localhost:5984/_config/', ('joe', 'secret')) 611 | >>> extract_credentials('http://joe%40example.com:secret@localhost:5984/_config/') 612 | ('http://localhost:5984/_config/', ('joe@example.com', 'secret')) 613 | """ 614 | parts = util.urlsplit(url) 615 | netloc = parts[1] 616 | if '@' in netloc: 617 | creds, netloc = netloc.split('@') 618 | credentials = tuple(util.urlunquote(i) for i in creds.split(':')) 619 | parts = list(parts) 620 | parts[1] = netloc 621 | else: 622 | credentials = None 623 | return util.urlunsplit(parts), credentials 624 | 625 | 626 | def basic_auth(credentials): 627 | """Generates authorization header value for given credentials. 628 | >>> basic_auth(('root', 'relax')) 629 | b'Basic cm9vdDpyZWxheA==' 630 | >>> basic_auth(None) 631 | >>> basic_auth(()) 632 | """ 633 | if credentials: 634 | token = b64encode(('%s:%s' % credentials).encode('utf-8')) 635 | return ('Basic %s' % token.strip().decode('utf-8')).encode('ascii') 636 | 637 | 638 | def quote(string, safe=''): 639 | if isinstance(string, util.utype): 640 | string = string.encode('utf-8') 641 | return util.urlquote(string, safe) 642 | 643 | 644 | def urlencode(data): 645 | if isinstance(data, dict): 646 | data = data.items() 647 | params = [] 648 | for name, value in data: 649 | if isinstance(value, util.utype): 650 | value = value.encode('utf-8') 651 | params.append((name, value)) 652 | return util.urlencode(params) 653 | 654 | 655 | def urljoin(base, *path, **query): 656 | """Assemble a uri based on a base, any number of path segments, and query 657 | string parameters. 658 | 659 | >>> urljoin('http://example.org', '_all_dbs') 660 | 'http://example.org/_all_dbs' 661 | 662 | A trailing slash on the uri base is handled gracefully: 663 | 664 | >>> urljoin('http://example.org/', '_all_dbs') 665 | 'http://example.org/_all_dbs' 666 | 667 | And multiple positional arguments become path parts: 668 | 669 | >>> urljoin('http://example.org/', 'foo', 'bar') 670 | 'http://example.org/foo/bar' 671 | 672 | All slashes within a path part are escaped: 673 | 674 | >>> urljoin('http://example.org/', 'foo/bar') 675 | 'http://example.org/foo%2Fbar' 676 | >>> urljoin('http://example.org/', 'foo', '/bar/') 677 | 'http://example.org/foo/%2Fbar%2F' 678 | 679 | >>> urljoin('http://example.org/', None) #doctest:+IGNORE_EXCEPTION_DETAIL 680 | Traceback (most recent call last): 681 | ... 682 | TypeError: argument 2 to map() must support iteration 683 | """ 684 | if base and base.endswith('/'): 685 | base = base[:-1] 686 | retval = [base] 687 | 688 | # build the path 689 | path = '/'.join([''] + [quote(s) for s in path]) 690 | if path: 691 | retval.append(path) 692 | 693 | # build the query string 694 | params = [] 695 | for name, value in query.items(): 696 | if type(value) in (list, tuple): 697 | params.extend([(name, i) for i in value if i is not None]) 698 | elif value is not None: 699 | if value is True: 700 | value = 'true' 701 | elif value is False: 702 | value = 'false' 703 | params.append((name, value)) 704 | if params: 705 | retval.extend(['?', urlencode(params)]) 706 | 707 | return ''.join(retval) 708 | 709 | --------------------------------------------------------------------------------