diff --git a/.travis.yml b/.travis.yml index 10cf83f6..cffb42b6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,11 +3,8 @@ python: 2.7 services: - couchdb env: - - TOX_ENV=py26 - TOX_ENV=py27 - - TOX_ENV=py33 - TOX_ENV=py34 - - TOX_ENV=py33-json - TOX_ENV=py34-json install: - pip install tox diff --git a/ChangeLog.rst b/ChangeLog.rst index 8fa6edb9..7cf2f217 100644 --- a/ChangeLog.rst +++ b/ChangeLog.rst @@ -1,3 +1,16 @@ +Version 1.2 (2018-02-09) +------------------------ + +* Fixed some issues relating to usage with Python 3 +* Remove support for Python 2.6 and 3.x with x < 4 +* Fix logging response in query server (fixes #321) +* Fix HTTP authentication password encoding (fixes #302) +* Add missing ``http.Forbidden`` error (fixes #305) +* Show ``doc`` property on ``Row`` string representation +* Add methods for mango queries and indexes +* Allow mango filters in ``_changes`` API + + Version 1.1 (2016-08-05) ------------------------ diff --git a/Makefile b/Makefile index bcd4e08c..f4628527 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ doc: python setup.py build_sphinx upload-doc: doc - python setup.py upload_sphinx + python2 setup.py upload_sphinx coverage: PYTHONPATH=. coverage run couchdb/tests/__main__.py diff --git a/README.rst b/README.rst index ae1e910b..65e0f029 100644 --- a/README.rst +++ b/README.rst @@ -4,6 +4,12 @@ CouchDB-Python Library .. image:: https://travis-ci.org/djc/couchdb-python.svg :target: https://travis-ci.org/djc/couchdb-python +**Note: CouchDB-Python is no longer being maintained. After 8 years of maintaining +CouchDB-Python, I no longer have time to address open issues and new bug reports. +Consider https://github.com/cloudant/python-cloudant as an alternative. +If you're interested in taking over maintenance of CouchDB-Python, please start a +discussion on the mailing list, or open an issue or PR.** + A Python library for working with CouchDB. `Downloads`_ are available via `PyPI`_. Our `documentation`_ is also hosted there. We have a `mailing list`_. @@ -22,11 +28,12 @@ It also provides a couple of command-line tools: Prerequisites: -* simplejson (or Python >= 2.6, which comes with a simplejson-based JSON module in the standard library) -* Python 2.6 or later +* Python 2.7, 3.4 or later * CouchDB 0.10.x or later (0.9.x should probably work, as well) +``simplejson`` will be used if installed. + .. _Downloads: http://pypi.python.org/pypi/CouchDB .. _PyPI: http://pypi.python.org/ -.. _documentation: http://packages.python.org/CouchDB/ +.. _documentation: http://couchdb-python.readthedocs.io/en/latest/ .. _mailing list: http://groups.google.com/group/couchdb-python diff --git a/couchdb/client.py b/couchdb/client.py index bf1fef3d..6125e831 100644 --- a/couchdb/client.py +++ b/couchdb/client.py @@ -85,6 +85,7 @@ def __init__(self, url=DEFAULT_BASE_URL, full_commit=True, session=None): self.resource = url # treat as a Resource object if not full_commit: self.resource.headers['X-Couch-Full-Commit'] = 'false' + self._version_info = None def __contains__(self, name): """Return whether the server contains a database with the specified @@ -166,6 +167,18 @@ def version(self): status, headers, data = self.resource.get_json() return data['version'] + def version_info(self): + """The version of the CouchDB server as a tuple of ints. + + Note that this results in a request being made only at the first call. + Afterwards the result will be cached. + + :rtype: `tuple(int, int, int)`""" + if self._version_info is None: + version = self.version() + self._version_info = tuple(map(int, version.split('.'))) + return self._version_info + def stats(self, name=None): """Server statistics. @@ -231,6 +244,7 @@ def replicate(self, source, target, **options): def add_user(self, name, password, roles=None): """Add regular user in authentication database. + :param name: name of regular user, normally user id :param password: password of regular user :param roles: roles of regular user @@ -248,6 +262,7 @@ def add_user(self, name, password, roles=None): def remove_user(self, name): """Remove regular user in authentication database. + :param name: name of regular user, normally user id """ user_db = self['_users'] @@ -256,6 +271,7 @@ def remove_user(self, name): def login(self, name, password): """Login regular user in couch db + :param name: name of regular user, normally user id :param password: password of regular user :return: authentication token @@ -273,6 +289,7 @@ def login(self, name, password): def logout(self, token): """Logout regular user in couch db + :param token: token of login user :return: True if successfully logout :rtype: bool @@ -286,6 +303,7 @@ def logout(self, token): def verify_token(self, token): """Verify user token + :param token: authentication token :return: True if authenticated ok :rtype: bool @@ -762,10 +780,80 @@ def put_attachment(self, doc, content, filename=None, content_type=None): }, rev=doc['_rev']) doc['_rev'] = data['rev'] + def find(self, mango_query, wrapper=None): + """Execute a mango find-query against the database. + + Note: only available for CouchDB version >= 2.0.0 + + More information on the `mango_query` structure can be found here: + http://docs.couchdb.org/en/master/api/database/find.html#find-selectors + + >>> server = Server() + >>> db = server.create('python-tests') + >>> db['johndoe'] = dict(type='Person', name='John Doe') + >>> db['maryjane'] = dict(type='Person', name='Mary Jane') + >>> db['gotham'] = dict(type='City', name='Gotham City') + >>> mango = {'selector': {'type': 'Person'}, + ... 'fields': ['name'], + ... 'sort':[{'name': 'asc'}]} + >>> for row in db.find(mango): # doctest: +SKIP + ... print(row['name']) # doctest: +SKIP + John Doe + Mary Jane + >>> del server['python-tests'] + + :param mango_query: a dictionary describing criteria used to select + documents + :param wrapper: an optional callable that should be used to wrap the + resulting documents + :return: the query results as a list of `Document` (or whatever `wrapper` returns) + """ + status, headers, data = self.resource.post_json('_find', mango_query) + return map(wrapper or Document, data.get('docs', [])) + + def explain(self, mango_query): + """Explain a mango find-query. + + Note: only available for CouchDB version >= 2.0.0 + + More information on the `mango_query` structure can be found here: + http://docs.couchdb.org/en/master/api/database/find.html#db-explain + + >>> server = Server() + >>> db = server.create('python-tests') + >>> db['johndoe'] = dict(type='Person', name='John Doe') + >>> db['maryjane'] = dict(type='Person', name='Mary Jane') + >>> db['gotham'] = dict(type='City', name='Gotham City') + >>> mango = {'selector': {'type': 'Person'}, 'fields': ['name']} + >>> db.explain(mango) #doctest: +ELLIPSIS +SKIP + {...} + >>> del server['python-tests'] + + :param mango_query: a `dict` describing criteria used to select + documents + :return: the query results as a list of `Document` (or whatever + `wrapper` returns) + :rtype: `dict` + """ + _, _, data = self.resource.post_json('_explain', mango_query) + return data + + def index(self): + """Get an object to manage the database indexes. + + :return: an `Indexes` object to manage the databes indexes + :rtype: `Indexes` + """ + return Indexes(self.resource('_index')) + + + def query(self, map_fun, reduce_fun=None, language='javascript', wrapper=None, **options): """Execute an ad-hoc query (a "temp view") against the database. + Note: not supported for CouchDB version >= 2.0.0 + >>> server = Server() >>> db = server.create('python-tests') >>> db['johndoe'] = dict(type='Person', name='John Doe') @@ -798,7 +886,7 @@ def query(self, map_fun, reduce_fun=None, language='javascript', :param wrapper: an optional callable that should be used to wrap the result rows :param options: optional query string parameters - :return: the view reults + :return: the view results :rtype: `ViewResults` """ return TemporaryView(self.resource('_temp_view'), map_fun, @@ -1025,7 +1113,12 @@ def update_doc(self, name, docid=None, **options): return headers, body def _changes(self, **opts): - _, _, data = self.resource.get('_changes', **opts) + # use streaming `get` and `post` methods + if opts.get('filter') == '_selector': + selector = opts.pop('_selector', None) + _, _, data = self.resource.post('_changes', selector, **opts) + else: + _, _, data = self.resource.get('_changes', **opts) lines = data.iterchunks() for ln in lines: if not ln: # skip heartbeats @@ -1044,7 +1137,12 @@ def changes(self, **opts): """ if opts.get('feed') == 'continuous': return self._changes(**opts) - _, _, data = self.resource.get_json('_changes', **opts) + + if opts.get('filter') == '_selector': + selector = opts.pop('_selector', None) + _, _, data = self.resource.post_json('_changes', selector, **opts) + else: + _, _, data = self.resource.get_json('_changes', **opts) return data @@ -1323,7 +1421,7 @@ class Row(dict): """Representation of a row as returned by database views.""" def __repr__(self): - keys = 'id', 'key', 'error', 'value' + keys = 'id', 'key', 'doc', 'error', 'value' items = ['%s=%r' % (k, self[k]) for k in keys if k in self] return '<%s %s>' % (type(self).__name__, ', '.join(items)) @@ -1355,3 +1453,111 @@ def doc(self): doc = self.get('doc') if doc: return Document(doc) + + +class Indexes(object): + """Manage indexes in CouchDB 2.0.0 and later. + + More information here: + http://docs.couchdb.org/en/2.0.0/api/database/find.html#db-index + """ + + def __init__(self, url, session=None): + if isinstance(url, util.strbase): + self.resource = http.Resource(url, session) + else: + self.resource = url + + def __setitem__(self, ddoc_name, index): + """Add an index to the database. + + >>> server = Server() + >>> db = server.create('python-tests') + >>> db['johndoe'] = dict(type='Person', name='John Doe') + >>> db['maryjane'] = dict(type='Person', name='Mary Jane') + >>> db['gotham'] = dict(type='City', name='Gotham City') + >>> idx = db.index() + >>> idx['foo', 'bar'] = [{'type': 'asc'}] #doctest: +SKIP + >>> list(idx) #doctest: +SKIP + [{'ddoc': None, + 'def': {'fields': [{'_id': 'asc'}]}, + 'name': '_all_docs', + 'type': 'special'}, + {'ddoc': '_design/foo', + 'def': {'fields': [{'type': 'asc'}]}, + 'name': 'bar', + 'type': 'json'}] + >>> idx[None, None] = [{'type': 'desc'}] #doctest: +SKIP + >>> list(idx) #doctest: +SKIP, +ELLIPSIS + [{'ddoc': None, + 'def': {'fields': [{'_id': 'asc'}]}, + 'name': '_all_docs', + 'type': 'special'}, + {'ddoc': '_design/...', + 'def': {'fields': [{'type': 'desc'}]}, + 'name': '...', + 'type': 'json'}, + {'ddoc': '_design/foo', + 'def': {'fields': [{'type': 'asc'}]}, + 'name': 'bar', + 'type': 'json'}] + >>> del server['python-tests'] + + :param index: `list` of indexes to create + :param ddoc_name: `tuple` or `list` containing first the name of the + design document, in which the index will be created, + and second name of the index. Both can be `None`. + """ + query = {'index': {'fields': index}} + ddoc, name = ddoc_name # expect ddoc / name to be a slice or list + if ddoc: + query['ddoc'] = ddoc + if name: + query['name'] = name + self.resource.post_json(body=query) + + def __delitem__(self, ddoc_name): + """Remove an index from the database. + + >>> server = Server() + >>> db = server.create('python-tests') + >>> db['johndoe'] = dict(type='Person', name='John Doe') + >>> db['maryjane'] = dict(type='Person', name='Mary Jane') + >>> db['gotham'] = dict(type='City', name='Gotham City') + >>> idx = db.index() + >>> idx['foo', 'bar'] = [{'type': 'asc'}] #doctest: +SKIP + >>> del idx['foo', 'bar'] #doctest: +SKIP + >>> list(idx) #doctest: +SKIP + [{'ddoc': None, + 'def': {'fields': [{'_id': 'asc'}]}, + 'name': '_all_docs', + 'type': 'special'}] + >>> del server['python-tests'] + + :param ddoc: name of the design document containing the index + :param name: name of the index that is to be removed + :return: `dict` containing the `id`, the `name` and the `result` of + creating the index + """ + self.resource.delete_json([ddoc_name[0], 'json', ddoc_name[1]]) + + def _list(self): + _, _, data = self.resource.get_json() + return data + + def __iter__(self): + """Iterate all indexes of the associated database. + + >>> server = Server() + >>> db = server.create('python-tests') + >>> idx = db.index() + >>> list(idx) #doctest: +SKIP + [{'ddoc': None, + 'def': {'fields': [{'_id': 'asc'}]}, + 'name': '_all_docs', + 'type': 'special'}] + >>> del server['python-tests'] + + :return: iterator yielding `dict`'s describing each index + """ + return iter(self._list()['indexes']) diff --git a/couchdb/http.py b/couchdb/http.py index 4a6750e9..d201f3e0 100644 --- a/couchdb/http.py +++ b/couchdb/http.py @@ -581,7 +581,10 @@ def _request(self, method, path=None, body=None, headers=None, **params): all_headers = self.headers.copy() all_headers.update(headers or {}) if path is not None: - url = urljoin(self.url, path, **params) + if isinstance(path, list): + url = urljoin(self.url, *path, **params) + else: + url = urljoin(self.url, path, **params) else: url = urljoin(self.url, **params) return self.session.request(method, url, body=body, diff --git a/couchdb/tests/client.py b/couchdb/tests/client.py index 5aec3939..c21f3940 100644 --- a/couchdb/tests/client.py +++ b/couchdb/tests/client.py @@ -374,6 +374,89 @@ def test_query_multi_get(self): for idx, i in enumerate(range(1, 6, 2)): self.assertEqual(i, res[idx].key) + def test_find(self): + if self.server.version_info()[0] < 2: + return + + docs = [ + dict(type='Person', name='John Doe'), + dict(type='Person', name='Mary Jane'), + dict(type='City', name='Gotham City') + ] + self.db.update(docs) + # the sort needs an index over `name`, the selector selects by `type` + idx = self.db.index() + idx['foo', 'bar'] = [{'type': 'asc'}, {'name': 'asc'}] + + res = list(self.db.find( + { + 'selector': { + 'type': 'Person' + }, + 'fields': ['name'], + # we need to specify the complete index here + 'sort': [{'type': 'asc'}, {'name': 'asc'}] + } + )) + self.assertEqual(2, len(res)) + expect = ['John Doe', 'Mary Jane'] + for i, doc in enumerate(res): + self.assertEqual(set(['name']), doc.keys()) + self.assertEqual(expect[i], doc['name']) + + def test_explain(self): + if self.server.version_info()[0] < 2: + return + mango = {'selector': {'type': 'Person'}, 'fields': ['name']} + res = self.db.explain(mango) + + self.assertEqual(['name'], res['fields']) + self.assertEqual({'type': {'$eq': 'Person'}}, res['selector']) + self.assertEqual(0, res['skip']) + self.assertEqual(self.db.name, res['dbname']) + + def test_index(self): + if self.server.version_info()[0] < 2: + return + + res = list(self.db.index()) + self.assertEqual(1, len(res)) + self.assertEqual({'ddoc': None, 'def': {'fields': [{'_id': 'asc'}]}, + 'name': '_all_docs', 'type': 'special'}, + res[0]) + + def test_add_index(self): + if self.server.version_info()[0] < 2: + return + + idx = self.db.index() + idx['foo', 'bar'] = [{'type': 'asc'}] + idxs = list(idx) + + self.assertEqual(2, len(idxs)) + for i in idxs: + if i['ddoc'] is not None: # special `_all_docs` index + self.assertEqual({'ddoc': '_design/foo', + 'def': {'fields': [{'type': 'asc'}]}, + 'name': 'bar', + 'type': 'json'}, + i) + return + self.failed() + + def test_remove_index(self): + if self.server.version_info()[0] < 2: + return + + idx = self.db.index() + idx['foo', 'bar'] = [{'type': 'asc'}] + res = list(idx) + self.assertEqual(2, len(res)) + del idx['foo', 'bar'] + + res = list(idx) + self.assertEqual(1, len(res)) + def test_bulk_update_conflict(self): docs = [ dict(type='Person', name='John Doe'), @@ -500,6 +583,28 @@ def test_changes_conn_usable(self): # in a good state from the previous request. self.assertTrue(self.db.info()['doc_count'] == 0) + def test_changes_conn_usable_selector(self): + if self.server.version_info()[0] < 2: + return + # Consume a changes feed to get a used connection in the pool. + list(self.db.changes(feed='continuous', + filter='_selector', + timeout=0, + _selector={'selector': {}})) + # Try using the connection again to make sure the connection was left + # in a good state from the previous request. + self.assertTrue(self.db.info()['doc_count'] == 0) + + def test_changes_usable_selector(self): + if self.server.version_info()[0] < 2: + return + # Consume a changes feed to get a used connection in the pool. + list(self.db.changes(filter='_selector', + _selector={'selector': {}})) + # Try using the connection again to make sure the connection was left + # in a good state from the previous request. + self.assertTrue(self.db.info()['doc_count'] == 0) + def test_changes_heartbeat(self): def wakeup(): time.sleep(.3) diff --git a/couchdb/tests/couch_tests.py b/couchdb/tests/couch_tests.py index f9e642eb..872690df 100644 --- a/couchdb/tests/couch_tests.py +++ b/couchdb/tests/couch_tests.py @@ -191,6 +191,31 @@ def test_utf8_encoding(self): for idx, row in enumerate(self.db.query(query)): self.assertEqual(texts[idx], row.key) + def test_update_with_unsafe_doc_ids(self): + doc_id = 'sanitise/the/doc/id/plz/' + design_doc = 'test_slashes_in_doc_ids' + handler_name = 'test' + func = """ + function(doc, req) { + doc.test = 'passed'; + return [doc, 'ok']; + } + """ + + # Stick an update handler in + self.db['_design/%s' % design_doc] = { + 'updates': {handler_name: func} + } + # And a test doc + self.db[doc_id] = {'test': 'failed'} + + response = self.db.update_doc( + '%s/%s' % (design_doc, handler_name), + docid=doc_id + ) + + self.assertEqual(self.db[doc_id]['test'], 'passed') + def test_design_docs(self): for i in range(50): self.db[str(i)] = {'integer': i, 'string': str(i)} diff --git a/couchdb/tests/view.py b/couchdb/tests/view.py index 79a18dd9..99f7f8d1 100644 --- a/couchdb/tests/view.py +++ b/couchdb/tests/view.py @@ -53,7 +53,7 @@ def test_map_doc_with_logging(self): view.run(input=input, output=output) self.assertEqual(output.getvalue(), b'true\n' - b'{"log": "running"}\n' + b'["log", "running"]\n' b'[[[null, {"foo": "bar"}]]]\n') def test_map_doc_with_logging_json(self): @@ -64,7 +64,7 @@ def test_map_doc_with_logging_json(self): view.run(input=input, output=output) self.assertEqual(output.getvalue(), b'true\n' - b'{"log": "[1, 2, 3]"}\n' + b'["log", "[1, 2, 3]"]\n' b'[[[null, {"foo": "bar"}]]]\n') def test_reduce(self): @@ -82,7 +82,7 @@ def test_reduce_with_logging(self): output = StringIO() view.run(input=input, output=output) self.assertEqual(output.getvalue(), - b'{"log": "Summing (1, 2, 3)"}\n' + b'["log", "Summing (1, 2, 3)"]\n' b'[true, [6]]\n') def test_rereduce(self): diff --git a/couchdb/view.py b/couchdb/view.py index 0bb7e315..1412996b 100755 --- a/couchdb/view.py +++ b/couchdb/view.py @@ -24,13 +24,15 @@ log = logging.getLogger('couchdb.view') -def run(input=sys.stdin, output=sys.stdout): +def run(input=sys.stdin, output=None): r"""CouchDB view function handler implementation for Python. :param input: the readable file-like object to read input from :param output: the writable file-like object to write output to """ functions = [] + if output is None: + output = sys.stdout if sys.version_info[0] < 3 else sys.stdout.buffer def _writejson(obj): obj = json.encode(obj) @@ -43,7 +45,7 @@ def _writejson(obj): def _log(message): if not isinstance(message, util.strbase): message = json.encode(message) - _writejson({'log': message}) + _writejson(['log', message]) def reset(config=None): del functions[:] @@ -55,15 +57,15 @@ def add_fun(string): try: util.pyexec(string, {'log': _log}, globals_) except Exception as e: - return {'error': { - 'id': 'map_compilation_error', - 'reason': e.args[0] - }} - err = {'error': { - 'id': 'map_compilation_error', - 'reason': 'string must eval to a function ' + return ['error', + 'map_compilation_error', + e.args[0] + ] + err = ['error', + 'map_compilation_error', + 'string must eval to a function ' '(ex: "def(doc): return 1")' - }} + ] if len(globals_) != 1: return err function = list(globals_.values())[0] @@ -93,15 +95,15 @@ def reduce(*cmd, **kwargs): except Exception as e: log.error('runtime error in reduce function: %s', e, exc_info=True) - return {'error': { - 'id': 'reduce_compilation_error', - 'reason': e.args[0] - }} - err = {'error': { - 'id': 'reduce_compilation_error', - 'reason': 'string must eval to a function ' + return ['error', + 'reduce_compilation_error', + e.args[0] + ] + err = ['error', + 'reduce_compilation_error', + 'string must eval to a function ' '(ex: "def(keys, values): return 1")' - }} + ] if len(globals_) != 1: return err function = list(globals_.values())[0] diff --git a/setup.py b/setup.py index 95af723e..9fdcbd65 100755 --- a/setup.py +++ b/setup.py @@ -39,7 +39,7 @@ setup( name = 'CouchDB', - version = '1.1.1', + version = '1.2.1', description = 'Python library for working with CouchDB', long_description = \ """This is a Python library for CouchDB. It provides a convenient high level @@ -58,8 +58,8 @@ 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', 'Topic :: Database :: Front-Ends', 'Topic :: Software Development :: Libraries :: Python Modules', ], diff --git a/tox.ini b/tox.ini index ae08bf98..53929648 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py26, py27, py33, py34, py33-json, py34-json, py35-json +envlist = py27, py34, py34-json, py35-json [testenv] deps = simplejson @@ -7,15 +7,6 @@ commands = python --version {envbindir}/python -m couchdb.tests -[testenv:py26] -commands = - python --version - {envbindir}/python -m couchdb.tests.__main__ - -[testenv:py33-json] -basepython = python3.3 -deps = - [testenv:py34-json] basepython = python3.4 deps =