├── 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 |
--------------------------------------------------------------------------------