From 3bdebdb1fe67f5d4777075191c6d92350c25f552 Mon Sep 17 00:00:00 2001 From: dancollins34 Date: Fri, 10 Nov 2017 17:08:08 -0500 Subject: [PATCH] Reorganize project while attempting to maintain as much backwards compatibility as possible This commit is intended to reorganize the project to increase code reuse and make the majority of the behavior more consistent. This implements changes so that every call, whether inside of a batch or async execution or using a database instance directly, will return a Job construct with a lazily loading result. This enables the use of asynchronous http requests as well as other futures-based http responses (like multiprocessing.futures for example). However, this is not enabled by default- it requires setting the old_behavior flag to False to maintain as much backwards compatibility as possible. This commit does make a few changes. Any behavioral changes (mostly around asynchronous job handling and user management) are marked with # TODO CHANGED . The other potential changes involve differences in import statements. I could use some guidance as to which classes are public facing and should be imported in the __init__.py file and which are intended to be internal. --- arango/__init__.py | 6 +- arango/api.py | 11 - arango/api/__init__.py | 1 + arango/api/api.py | 18 + arango/api/collections/__init__.py | 4 + arango/{ => api}/collections/base.py | 222 ++- arango/{ => api}/collections/edge.py | 20 +- arango/{ => api}/collections/standard.py | 51 +- arango/{ => api}/collections/vertex.py | 20 +- arango/api/cursors/__init__.py | 2 + arango/{cursor.py => api/cursors/base.py} | 95 +- arango/api/cursors/export.py | 13 + arango/api/databases/__init__.py | 2 + arango/{database.py => api/databases/base.py} | 1043 ++++++++--- arango/api/databases/system.py | 140 ++ arango/{ => api}/wal.py | 127 +- arango/api/wrappers/__init__.py | 2 + arango/{ => api/wrappers}/aql.py | 43 +- arango/{ => api/wrappers}/graph.py | 24 +- arango/async.py | 240 --- arango/client.py | 1075 +---------- arango/collections/__init__.py | 3 - arango/connection.py | 314 ---- arango/connections/__init__.py | 5 + arango/connections/base.py | 259 +++ arango/connections/executions/__init__.py | 4 + arango/connections/executions/async.py | 66 + arango/{ => connections/executions}/batch.py | 213 +-- .../{ => connections/executions}/cluster.py | 69 +- .../executions}/transaction.py | 111 +- arango/exceptions.py | 535 ------ arango/exceptions/__init__.py | 17 + arango/exceptions/aql.py | 41 + arango/exceptions/async.py | 29 + arango/exceptions/base.py | 56 + arango/exceptions/batch.py | 5 + arango/exceptions/collection.py | 61 + arango/exceptions/cursor.py | 13 + arango/exceptions/database.py | 21 + arango/exceptions/document.py | 37 + arango/exceptions/graph.py | 61 + arango/exceptions/index.py | 17 + arango/exceptions/job.py | 5 + arango/exceptions/pregel.py | 17 + arango/exceptions/server.py | 73 + arango/exceptions/task.py | 21 + arango/exceptions/transaction.py | 5 + arango/exceptions/user.py | 41 + arango/exceptions/wal.py | 21 + arango/http_clients/__init__.py | 5 +- arango/http_clients/asyncio.py | 362 ++-- arango/http_clients/base.py | 116 +- arango/http_clients/default.py | 214 +-- arango/jobs/__init__.py | 4 + arango/jobs/async.py | 230 +++ arango/jobs/base.py | 83 + arango/jobs/batch.py | 49 + arango/jobs/old.py | 10 + arango/lock.py | 36 - arango/request.py | 16 +- arango/responses/__init__.py | 2 + arango/{response.py => responses/base.py} | 53 +- arango/responses/lazy.py | 32 + arango/utils/__init__.py | 3 + arango/utils/constants.py | 5 + arango/{utils.py => utils/functions.py} | 19 +- arango/utils/lock.py | 67 + arango/version.py | 1 - out.txt | 67 + scripts/setup_arangodb_mac.sh | 2 +- setup.py | 2 +- tests/test_aql.py | 20 +- tests/test_async.py | 91 +- tests/test_asyncio.py | 6 +- tests/test_batch.py | 27 +- tests/test_client.py | 12 +- tests/test_cluster.py | 10 +- tests/test_collection.py | 28 +- tests/test_database.py | 10 +- tests/test_document.py | 6 + tests/test_graph.py | 4 +- tests/test_new_client_document.py | 1645 +++++++++++++++++ tests/test_pregel.py | 3 +- tests/test_transaction.py | 13 +- tests/test_user.py | 17 +- tests/test_version.py | 2 +- tests/utils.py | 2 +- 87 files changed, 4984 insertions(+), 3569 deletions(-) delete mode 100644 arango/api.py create mode 100644 arango/api/__init__.py create mode 100644 arango/api/api.py create mode 100644 arango/api/collections/__init__.py rename arango/{ => api}/collections/base.py (89%) rename arango/{ => api}/collections/edge.py (95%) rename arango/{ => api}/collections/standard.py (96%) rename arango/{ => api}/collections/vertex.py (95%) create mode 100644 arango/api/cursors/__init__.py rename arango/{cursor.py => api/cursors/base.py} (63%) create mode 100644 arango/api/cursors/export.py create mode 100644 arango/api/databases/__init__.py rename arango/{database.py => api/databases/base.py} (63%) create mode 100644 arango/api/databases/system.py rename arango/{ => api}/wal.py (58%) create mode 100644 arango/api/wrappers/__init__.py rename arango/{ => api/wrappers}/aql.py (92%) rename arango/{ => api/wrappers}/graph.py (95%) delete mode 100644 arango/async.py delete mode 100644 arango/collections/__init__.py delete mode 100644 arango/connection.py create mode 100644 arango/connections/__init__.py create mode 100644 arango/connections/base.py create mode 100644 arango/connections/executions/__init__.py create mode 100644 arango/connections/executions/async.py rename arango/{ => connections/executions}/batch.py (50%) rename arango/{ => connections/executions}/cluster.py (55%) rename arango/{ => connections/executions}/transaction.py (68%) delete mode 100644 arango/exceptions.py create mode 100644 arango/exceptions/__init__.py create mode 100644 arango/exceptions/aql.py create mode 100644 arango/exceptions/async.py create mode 100644 arango/exceptions/base.py create mode 100644 arango/exceptions/batch.py create mode 100644 arango/exceptions/collection.py create mode 100644 arango/exceptions/cursor.py create mode 100644 arango/exceptions/database.py create mode 100644 arango/exceptions/document.py create mode 100644 arango/exceptions/graph.py create mode 100644 arango/exceptions/index.py create mode 100644 arango/exceptions/job.py create mode 100644 arango/exceptions/pregel.py create mode 100644 arango/exceptions/server.py create mode 100644 arango/exceptions/task.py create mode 100644 arango/exceptions/transaction.py create mode 100644 arango/exceptions/user.py create mode 100644 arango/exceptions/wal.py create mode 100644 arango/jobs/__init__.py create mode 100644 arango/jobs/async.py create mode 100644 arango/jobs/base.py create mode 100644 arango/jobs/batch.py create mode 100644 arango/jobs/old.py delete mode 100644 arango/lock.py create mode 100644 arango/responses/__init__.py rename arango/{response.py => responses/base.py} (63%) create mode 100644 arango/responses/lazy.py create mode 100644 arango/utils/__init__.py create mode 100644 arango/utils/constants.py rename arango/{utils.py => utils/functions.py} (51%) create mode 100644 arango/utils/lock.py delete mode 100644 arango/version.py create mode 100644 out.txt create mode 100644 tests/test_new_client_document.py diff --git a/arango/__init__.py b/arango/__init__.py index 936d31a5..892e1eb2 100644 --- a/arango/__init__.py +++ b/arango/__init__.py @@ -1,2 +1,4 @@ -from arango.client import ArangoClient # noqa: F401 -from arango.exceptions import ArangoError # noqa: F401 +from arango.utils.lock import RLock # noqa: F401 +from .request import Request # noqa: F401 +from arango.api.wal import WriteAheadLog # noqa: F401 +from .client import ArangoClient # noqa: F401 diff --git a/arango/api.py b/arango/api.py deleted file mode 100644 index 8a9bb72f..00000000 --- a/arango/api.py +++ /dev/null @@ -1,11 +0,0 @@ -from abc import ABCMeta - - -class APIWrapper: - __metaclass__ = ABCMeta - - def __init__(self, connection): - self._conn = connection - - def handle_request(self, request, handler): - return self._conn.handle_request(request, handler) diff --git a/arango/api/__init__.py b/arango/api/__init__.py new file mode 100644 index 00000000..d1e2ea38 --- /dev/null +++ b/arango/api/__init__.py @@ -0,0 +1 @@ +from .api import APIWrapper diff --git a/arango/api/api.py b/arango/api/api.py new file mode 100644 index 00000000..4100a679 --- /dev/null +++ b/arango/api/api.py @@ -0,0 +1,18 @@ +from abc import ABCMeta + + +class APIWrapper: + __metaclass__ = ABCMeta + + def __init__(self, connection): + self._conn = connection + + def handle_request(self, request, handler, job_class=None, + use_underlying=False, **kwargs): + if use_underlying: + connection = self._conn.underlying + else: + connection = self._conn + + return connection.handle_request(request, handler, job_class=job_class, + **kwargs) diff --git a/arango/api/collections/__init__.py b/arango/api/collections/__init__.py new file mode 100644 index 00000000..b4c4d5a4 --- /dev/null +++ b/arango/api/collections/__init__.py @@ -0,0 +1,4 @@ +from .base import BaseCollection +from .standard import Collection +from .edge import EdgeCollection # noqa: F401 +from .vertex import VertexCollection diff --git a/arango/collections/base.py b/arango/api/collections/base.py similarity index 89% rename from arango/collections/base.py rename to arango/api/collections/base.py index 611804a2..69ff53f4 100644 --- a/arango/collections/base.py +++ b/arango/api/collections/base.py @@ -1,7 +1,8 @@ from __future__ import absolute_import, unicode_literals +from arango import Request from arango.api import APIWrapper -from arango.cursor import Cursor, ExportCursor +from arango.api.cursors import BaseCursor, ExportCursor from arango.exceptions import ( CollectionBadStatusError, CollectionChecksumError, @@ -24,8 +25,8 @@ UserRevokeAccessError, UserGrantAccessError ) -from arango.request import Request from arango.utils import HTTP_OK +from arango.jobs import BaseJob class BaseCollection(APIWrapper): @@ -59,17 +60,25 @@ def __iter__(self): """Iterate through the documents in the collection. :returns: the document cursor - :rtype: arango.cursor.Cursor + :rtype: arango.cursor.BaseCursor :raises arango.exceptions.DocumentGetError: if the documents cannot be fetched from the collection """ - res = self._conn.put( - endpoint='/_api/simple/all', + + request = Request( + method="put", + url='/_api/simple/all', data={'collection': self._name} ) - if res.status_code not in HTTP_OK: - raise DocumentGetError(res) - return Cursor(self._conn, res.body) + + def handler(res): + if res.status_code not in HTTP_OK: + raise DocumentGetError(res) + return BaseCursor(self._conn, res.body) + + job = self.handle_request(request, handler, job_class=BaseJob) + + return job.result(raise_errors=True) def __len__(self): """Return the number of documents in the collection. @@ -79,10 +88,20 @@ def __len__(self): :raises arango.exceptions.DocumentCountError: if the document count cannot be retrieved """ - res = self._conn.get('/_api/collection/{}/count'.format(self._name)) - if res.status_code not in HTTP_OK: - raise DocumentCountError(res) - return res.body['count'] + + request = Request( + method="get", + url='/_api/collection/{}/count'.format(self._name) + ) + + def handler(res): + if res.status_code not in HTTP_OK: + raise DocumentCountError(res) + return res.body['count'] + + job = self.handle_request(request, handler, job_class=BaseJob) + + return job.result(raise_errors=True) def __getitem__(self, key): """Return a document by its key from the collection. @@ -94,12 +113,22 @@ def __getitem__(self, key): :raises arango.exceptions.DocumentGetError: if the document cannot be fetched from the collection """ - res = self._conn.get('/_api/document/{}/{}'.format(self._name, key)) - if res.status_code == 404 and res.error_code == 1202: - return None - elif res.status_code not in HTTP_OK: - raise DocumentGetError(res) - return res.body + + request = Request( + method="get", + url='/_api/document/{}/{}'.format(self._name, key) + ) + + def handler(res): + if res.status_code == 404 and res.error_code == 1202: + return None + elif res.status_code not in HTTP_OK: + raise DocumentGetError(res) + return res.body + + job = self.handle_request(request, handler, job_class=BaseJob) + + return job.result(raise_errors=True) def __contains__(self, key): """Check if a document exists in the collection by its key. @@ -111,12 +140,24 @@ def __contains__(self, key): :raises arango.exceptions.DocumentInError: if the check cannot be executed """ - res = self._conn.get('/_api/document/{}/{}'.format(self._name, key)) - if res.status_code == 404 and res.error_code == 1202: - return False - elif res.status_code in HTTP_OK: + + request = Request( + method="get", + url='/_api/document/{}/{}'.format(self._name, key) + ) + + def handler(res): + if res.status_code not in HTTP_OK: + if res.status_code == 404 and res.error_code == 1202: + return False + + raise DocumentInError(res) + return True - raise DocumentInError(res) + + job = self.handle_request(request, handler, job_class=BaseJob) + + return job.result(raise_errors=True) def _status(self, code): """Return the collection status text. @@ -167,7 +208,7 @@ def rename(self, new_name): """ request = Request( method='put', - endpoint='/_api/collection/{}/rename'.format(self._name), + url='/_api/collection/{}/rename'.format(self._name), data={'name': new_name} ) @@ -195,7 +236,7 @@ def statistics(self): """ request = Request( method='get', - endpoint='/_api/collection/{}/figures'.format(self._name) + url='/_api/collection/{}/figures'.format(self._name) ) def handler(res): @@ -223,7 +264,7 @@ def revision(self): """ request = Request( method='get', - endpoint='/_api/collection/{}/revision'.format(self._name) + url='/_api/collection/{}/revision'.format(self._name) ) def handler(res): @@ -243,7 +284,7 @@ def properties(self): """ request = Request( method='get', - endpoint='/_api/collection/{}/properties'.format(self._name) + url='/_api/collection/{}/properties'.format(self._name) ) def handler(res): @@ -292,7 +333,7 @@ def configure(self, sync=None, journal_size=None): request = Request( method='put', - endpoint='/_api/collection/{}/properties'.format(self._name), + url='/_api/collection/{}/properties'.format(self._name), data=data ) @@ -330,8 +371,8 @@ def load(self): """ request = Request( method='put', - endpoint='/_api/collection/{}/load'.format(self._name), - command='db.{}.unload()'.format(self._name) + url='/_api/collection/{}/load'.format(self._name), + command='db.{}.load()'.format(self._name) ) def handler(res): @@ -351,7 +392,7 @@ def unload(self): """ request = Request( method='put', - endpoint='/_api/collection/{}/unload'.format(self._name), + url='/_api/collection/{}/unload'.format(self._name), command='db.{}.unload()'.format(self._name) ) @@ -372,7 +413,7 @@ def rotate(self): """ request = Request( method='put', - endpoint='/_api/collection/{}/rotate'.format(self._name), + url='/_api/collection/{}/rotate'.format(self._name), command='db.{}.rotate()'.format(self._name) ) @@ -399,7 +440,7 @@ def checksum(self, with_rev=False, with_data=False): """ request = Request( method='get', - endpoint='/_api/collection/{}/checksum'.format(self._name), + url='/_api/collection/{}/checksum'.format(self._name), params={'withRevision': with_rev, 'withData': with_data} ) @@ -420,7 +461,7 @@ def truncate(self): """ request = Request( method='put', - endpoint='/_api/collection/{}/truncate'.format(self._name), + url='/_api/collection/{}/truncate'.format(self._name), command='db.{}.truncate()'.format(self._name) ) @@ -447,7 +488,7 @@ def count(self): """ request = Request( method='get', - endpoint='/_api/collection/{}/count'.format(self._name) + url='/_api/collection/{}/count'.format(self._name) ) def handler(res): @@ -477,13 +518,19 @@ def has(self, key, rev=None, match_rev=True): :raises arango.exceptions.DocumentInError: if the check cannot be executed """ + + headers = {} + + if rev is not None: + if match_rev: + headers["If-Match"] = rev + else: + headers["If-None-Match"] = rev + request = Request( method='get', # TODO async seems to freeze when using 'head' - endpoint='/_api/document/{}/{}'.format(self._name, key), - headers=( - {'If-Match' if match_rev else 'If-None-Match': rev} - if rev is not None else {} - ) + url='/_api/document/{}/{}'.format(self._name, key), + headers=headers ) def handler(res): @@ -505,7 +552,7 @@ def all(self, :param limit: the max number of documents fetched by the cursor :type limit: int :returns: the document cursor - :rtype: arango.cursor.Cursor + :rtype: arango.cursor.BaseCursor :raises arango.exceptions.DocumentGetError: if the documents in the collection cannot be retrieved """ @@ -518,14 +565,14 @@ def all(self, request = Request( method='put', - endpoint='/_api/simple/all', + url='/_api/simple/all', data=data ) def handler(res): if res.status_code not in HTTP_OK: raise DocumentGetError(res) - return Cursor(self._conn, res.body) + return BaseCursor(self._conn, res.body) return self.handle_request(request, handler) @@ -585,7 +632,7 @@ def export(self, } request = Request( method='post', - endpoint='/_api/export', + url='/_api/export', params={'collection': self._name}, data=data ) @@ -607,7 +654,7 @@ def find(self, filters, offset=None, limit=None): :param limit: the max number of documents to return :type limit: int :returns: the document cursor - :rtype: arango.cursor.Cursor + :rtype: arango.cursor.BaseCursor :raises arango.exceptions.DocumentGetError: if the document cannot be fetched from the collection """ @@ -619,14 +666,14 @@ def find(self, filters, offset=None, limit=None): request = Request( method='put', - endpoint='/_api/simple/by-example', + url='/_api/simple/by-example', data=data ) def handler(res): if res.status_code not in HTTP_OK: raise DocumentGetError(res) - return Cursor(self._conn, res.body) + return BaseCursor(self._conn, res.body) return self.handle_request(request, handler) @@ -642,7 +689,7 @@ def get_many(self, keys): """ request = Request( method='put', - endpoint='/_api/simple/lookup-by-keys', + url='/_api/simple/lookup-by-keys', data={'collection': self._name, 'keys': keys} ) @@ -663,7 +710,7 @@ def random(self): """ request = Request( method='put', - endpoint='/_api/simple/any', + url='/_api/simple/any', data={'collection': self._name} ) @@ -689,7 +736,7 @@ def find_near(self, latitude, longitude, limit=None): :param limit: the max number of documents to return :type limit: int :returns: the document cursor - :rtype: arango.cursor.Cursor + :rtype: arango.cursor.BaseCursor :raises arango.exceptions.DocumentGetError: if the documents cannot be fetched from the collection @@ -697,10 +744,16 @@ def find_near(self, latitude, longitude, limit=None): A geo index must be defined in the collection for this method to be used """ + + if limit is None: + limit_string = "" + else: + limit_string = ', @limit' + full_query = """ FOR doc IN NEAR(@collection, @latitude, @longitude{}) RETURN doc - """.format(', @limit' if limit is not None else '') + """.format(limit_string) bind_vars = { 'collection': self._name, @@ -712,14 +765,14 @@ def find_near(self, latitude, longitude, limit=None): request = Request( method='post', - endpoint='/_api/cursor', + url='/_api/cursor', data={'query': full_query, 'bindVars': bind_vars} ) def handler(res): if res.status_code not in HTTP_OK: raise DocumentGetError(res) - return Cursor(self._conn, res.body) + return BaseCursor(self._conn, res.body) return self.handle_request(request, handler) @@ -745,7 +798,7 @@ def find_in_range(self, :param inclusive: include the lower and upper bounds :type inclusive: bool :returns: the document cursor - :rtype: arango.cursor.Cursor + :rtype: arango.cursor.BaseCursor :raises arango.exceptions.DocumentGetError: if the documents cannot be fetched from the collection @@ -778,14 +831,14 @@ def find_in_range(self, request = Request( method='post', - endpoint='/_api/cursor', + url='/_api/cursor', data={'query': full_query, 'bindVars': bind_vars} ) def handler(res): if res.status_code not in HTTP_OK: raise DocumentGetError(res) - return Cursor(self._conn, res.body) + return BaseCursor(self._conn, res.body) return self.handle_request(request, handler) @@ -802,7 +855,7 @@ def find_in_radius(self, latitude, longitude, radius, distance_field=None): :param distance_field: the key containing the distance :type distance_field: str | unicode :returns: the document cursor - :rtype: arango.cursor.Cursor + :rtype: arango.cursor.BaseCursor :raises arango.exceptions.DocumentGetError: if the documents cannot be fetched from the collection @@ -810,10 +863,16 @@ def find_in_radius(self, latitude, longitude, radius, distance_field=None): A geo index must be defined in the collection for this method to be used """ + + if distance_field: + distance_string = ", @distance" + else: + distance_string = "" + full_query = """ FOR doc IN WITHIN(@collection, @latitude, @longitude, @radius{}) RETURN doc - """.format(', @distance' if distance_field is not None else '') + """.format(distance_string) bind_vars = { 'collection': self._name, @@ -826,14 +885,14 @@ def find_in_radius(self, latitude, longitude, radius, distance_field=None): request = Request( method='post', - endpoint='/_api/cursor', + url='/_api/cursor', data={'query': full_query, 'bindVars': bind_vars} ) def handler(res): if res.status_code not in HTTP_OK: raise DocumentGetError(res) - return Cursor(self._conn, res.body) + return BaseCursor(self._conn, res.body) return self.handle_request(request, handler) @@ -863,7 +922,7 @@ def find_in_box(self, :param geo_field: the field to use for geo index :type geo_field: str | unicode :returns: the document cursor - :rtype: arango.cursor.Cursor + :rtype: arango.cursor.BaseCursor :raises arango.exceptions.DocumentGetError: if the documents cannot be fetched from the collection """ @@ -883,14 +942,14 @@ def find_in_box(self, request = Request( method='put', - endpoint='/_api/simple/within-rectangle', + url='/_api/simple/within-rectangle', data=data ) def handler(res): if res.status_code not in HTTP_OK: raise DocumentGetError(res) - return Cursor(self._conn, res.body) + return BaseCursor(self._conn, res.body) return self.handle_request(request, handler) @@ -904,14 +963,20 @@ def find_by_text(self, key, query, limit=None): :param limit: the max number of documents to return :type limit: int :returns: the document cursor - :rtype: arango.cursor.Cursor + :rtype: arango.cursor.BaseCursor :raises arango.exceptions.DocumentGetError: if the documents cannot be fetched from the collection """ + + if limit: + limit_string = ", @limit" + else: + limit_string = "" + full_query = """ FOR doc IN FULLTEXT(@collection, @field, @query{}) RETURN doc - """.format(', @limit' if limit is not None else '') + """.format(limit_string) bind_vars = { 'collection': self._name, @@ -923,14 +988,14 @@ def find_by_text(self, key, query, limit=None): request = Request( method='post', - endpoint='/_api/cursor', + url='/_api/cursor', data={'query': full_query, 'bindVars': bind_vars} ) def handler(res): if res.status_code not in HTTP_OK: raise DocumentGetError(res) - return Cursor(self._conn, res.body) + return BaseCursor(self._conn, res.body) return self.handle_request(request, handler) @@ -945,7 +1010,7 @@ def indexes(self): """ request = Request( method='get', - endpoint='/_api/index', + url='/_api/index', params={'collection': self._name} ) @@ -973,7 +1038,7 @@ def _add_index(self, data): """Helper method for creating a new index.""" request = Request( method='post', - endpoint='/_api/index', + url='/_api/index', data=data, params={'collection': self._name} ) @@ -1159,7 +1224,7 @@ def delete_index(self, index_id, ignore_missing=False): """ request = Request( method='delete', - endpoint='/_api/index/{}/{}'.format(self._name, index_id) + url='/_api/index/{}/{}'.format(self._name, index_id) ) def handler(res): @@ -1186,16 +1251,19 @@ def user_access(self, username): """ request = Request( method='get', - endpoint='/_api/user/{}/database/{}/{}'.format( + url='/_api/user/{}/database/{}/{}'.format( username, self.database, self.name ) ) def handler(res): - if res.status_code in HTTP_OK: - result = res.body['result'].lower() - return None if result == 'none' else result - raise UserAccessError(res) + if res.status_code not in HTTP_OK: + raise UserAccessError(res) + result = res.body['result'].lower() + if result == 'none': + return None + else: + return result return self.handle_request(request, handler) @@ -1212,7 +1280,7 @@ def grant_user_access(self, username): """ request = Request( method='put', - endpoint='/_api/user/{}/database/{}/{}'.format( + url='/_api/user/{}/database/{}/{}'.format( username, self.database, self.name ), data={'grant': 'rw'} @@ -1238,7 +1306,7 @@ def revoke_user_access(self, username): """ request = Request( method='delete', - endpoint='/_api/user/{}/database/{}/{}'.format( + url='/_api/user/{}/database/{}/{}'.format( username, self.database, self.name ) ) diff --git a/arango/collections/edge.py b/arango/api/collections/edge.py similarity index 95% rename from arango/collections/edge.py rename to arango/api/collections/edge.py index c643f0da..06323bcf 100644 --- a/arango/collections/edge.py +++ b/arango/api/collections/edge.py @@ -1,6 +1,6 @@ from __future__ import absolute_import, unicode_literals -from arango.collections.base import BaseCollection +from arango.api.collections import BaseCollection from arango.exceptions import ( DocumentGetError, DocumentDeleteError, @@ -66,12 +66,18 @@ def get(self, key, rev=None): :raises arango.exceptions.DocumentGetError: if the document cannot be fetched from the collection """ + + headers = {} + + if rev is not None: + headers["If-Match"] = rev + request = Request( method='get', - endpoint='/_api/gharial/{}/edge/{}/{}'.format( + url='/_api/gharial/{}/edge/{}/{}'.format( self._graph_name, self._name, key ), - headers={'If-Match': rev} if rev else {} + headers=headers ) def handler(res): @@ -107,7 +113,7 @@ def insert(self, document, sync=None): request = Request( method='post', - endpoint="/_api/gharial/{}/edge/{}".format( + url="/_api/gharial/{}/edge/{}".format( self._graph_name, self._name ), data=document, @@ -153,7 +159,7 @@ def update(self, document, keep_none=True, sync=None): request = Request( method='patch', - endpoint='/_api/gharial/{}/edge/{}/{}'.format( + url='/_api/gharial/{}/edge/{}/{}'.format( self._graph_name, self._name, document['_key'] ), data=document, @@ -199,7 +205,7 @@ def replace(self, document, sync=None): request = Request( method='put', - endpoint='/_api/gharial/{}/edge/{}/{}'.format( + url='/_api/gharial/{}/edge/{}/{}'.format( self._graph_name, self._name, document['_key'] ), data=document, @@ -249,7 +255,7 @@ def delete(self, document, ignore_missing=False, sync=None): request = Request( method='delete', - endpoint='/_api/gharial/{}/edge/{}/{}'.format( + url='/_api/gharial/{}/edge/{}/{}'.format( self._graph_name, self._name, document['_key'] ), params=params, diff --git a/arango/collections/standard.py b/arango/api/collections/standard.py similarity index 96% rename from arango/collections/standard.py rename to arango/api/collections/standard.py index 0c42a751..7dcd7972 100644 --- a/arango/collections/standard.py +++ b/arango/api/collections/standard.py @@ -3,7 +3,7 @@ from json import dumps from six import string_types -from arango.collections.base import BaseCollection +from arango.api.collections import BaseCollection from arango.exceptions import ( DocumentDeleteError, DocumentGetError, @@ -58,13 +58,19 @@ def get(self, key, rev=None, match_rev=True): :raises arango.exceptions.DocumentGetError: if the document cannot be retrieved from the collection """ + + headers = {} + + if rev is not None: + if match_rev: + headers["If-Match"] = rev + else: + headers["If-None-Match"] = rev + request = Request( method='get', - endpoint='/_api/document/{}/{}'.format(self._name, key), - headers=( - {'If-Match' if match_rev else 'If-None-Match': rev} - if rev is not None else {} - ) + url='/_api/document/{}/{}'.format(self._name, key), + headers=headers ) def handler(res): @@ -116,7 +122,7 @@ def insert(self, document, return_new=False, sync=None): request = Request( method='post', - endpoint='/_api/document/{}'.format(self._name), + url='/_api/document/{}'.format(self._name), data=document, params=params, command=command @@ -171,7 +177,7 @@ def insert_many(self, documents, return_new=False, sync=None): request = Request( method='post', - endpoint='/_api/document/{}'.format(self._name), + url='/_api/document/{}'.format(self._name), data=documents, params=params, command=command @@ -265,7 +271,7 @@ def update(self, request = Request( method='patch', - endpoint='/_api/document/{}/{}'.format( + url='/_api/document/{}/{}'.format( self._name, document['_key'] ), data=document, @@ -358,7 +364,7 @@ def update_many(self, request = Request( method='patch', - endpoint='/_api/document/{}'.format(self._name), + url='/_api/document/{}'.format(self._name), data=documents, params=params, command=command @@ -439,7 +445,7 @@ def update_match(self, request = Request( method='put', - endpoint='/_api/simple/update-by-example', + url='/_api/simple/update-by-example', data=data, command=command ) @@ -515,7 +521,7 @@ def replace(self, request = Request( method='put', - endpoint='/_api/document/{}/{}'.format( + url='/_api/document/{}/{}'.format( self._name, document['_key'] ), params=params, @@ -598,7 +604,7 @@ def replace_many(self, request = Request( method='put', - endpoint='/_api/document/{}'.format(self._name), + url='/_api/document/{}'.format(self._name), params=params, data=documents, command=command @@ -669,7 +675,7 @@ def replace_match(self, filters, body, limit=None, sync=None): request = Request( method='put', - endpoint='/_api/simple/replace-by-example', + url='/_api/simple/replace-by-example', data=data, command=command ) @@ -731,19 +737,24 @@ def delete(self, else: headers = {} + if full_doc: + doc_target = document + else: + doc_target = {'_key': document} + if self._conn.type != 'transaction': command = None else: command = 'db.{}.remove({},{})'.format( self._name, - dumps(document if full_doc else {'_key': document}), + dumps(doc_target), dumps(params) ) request = Request( method='delete', - endpoint='/_api/document/{}/{}'.format( - self._name, document['_key'] if full_doc else document + url='/_api/document/{}/{}'.format( + self._name, doc_target['_key'] ), params=params, headers=headers, @@ -812,7 +823,7 @@ def delete_many(self, request = Request( method='delete', - endpoint='/_api/document/{}'.format(self._name), + url='/_api/document/{}'.format(self._name), params=params, data=documents, command=command @@ -865,7 +876,7 @@ def delete_match(self, filters, limit=None, sync=None): request = Request( method='put', - endpoint='/_api/simple/remove-by-example', + url='/_api/simple/remove-by-example', data=data, command='db.{}.removeByExample({}, {})'.format( self._name, @@ -978,7 +989,7 @@ def import_bulk(self, request = Request( method='post', - endpoint='/_api/import', + url='/_api/import', data=documents, params=params ) diff --git a/arango/collections/vertex.py b/arango/api/collections/vertex.py similarity index 95% rename from arango/collections/vertex.py rename to arango/api/collections/vertex.py index 1be85146..291a935f 100644 --- a/arango/collections/vertex.py +++ b/arango/api/collections/vertex.py @@ -1,6 +1,6 @@ from __future__ import absolute_import, unicode_literals -from arango.collections.base import BaseCollection +from arango.api.collections import BaseCollection from arango.exceptions import ( DocumentDeleteError, DocumentGetError, @@ -68,12 +68,18 @@ def get(self, key, rev=None): :raises arango.exceptions.DocumentGetError: if the document cannot be fetched from the collection """ + + headers = {} + + if rev is not None: + headers["If-Match"] = rev + request = Request( method='get', - endpoint='/_api/gharial/{}/vertex/{}/{}'.format( + url='/_api/gharial/{}/vertex/{}/{}'.format( self._graph_name, self._name, key ), - headers={'If-Match': rev} if rev else {} + headers=headers ) def handler(res): @@ -108,7 +114,7 @@ def insert(self, document, sync=None): request = Request( method='post', - endpoint='/_api/gharial/{}/vertex/{}'.format( + url='/_api/gharial/{}/vertex/{}'.format( self._graph_name, self._name ), data=document, @@ -155,7 +161,7 @@ def update(self, document, keep_none=True, sync=None): request = Request( method='patch', - endpoint='/_api/gharial/{}/vertex/{}/{}'.format( + url='/_api/gharial/{}/vertex/{}/{}'.format( self._graph_name, self._name, document['_key'] ), data=document, @@ -203,7 +209,7 @@ def replace(self, document, sync=None): request = Request( method='put', - endpoint='/_api/gharial/{}/vertex/{}/{}'.format( + url='/_api/gharial/{}/vertex/{}/{}'.format( self._graph_name, self._name, document['_key'] ), params=params, @@ -253,7 +259,7 @@ def delete(self, document, ignore_missing=False, sync=None): request = Request( method='delete', - endpoint='/_api/gharial/{}/vertex/{}/{}'.format( + url='/_api/gharial/{}/vertex/{}/{}'.format( self._graph_name, self._name, document['_key'] ), params=params, diff --git a/arango/api/cursors/__init__.py b/arango/api/cursors/__init__.py new file mode 100644 index 00000000..6b4c9f4e --- /dev/null +++ b/arango/api/cursors/__init__.py @@ -0,0 +1,2 @@ +from .base import BaseCursor +from .export import ExportCursor diff --git a/arango/cursor.py b/arango/api/cursors/base.py similarity index 63% rename from arango/cursor.py rename to arango/api/cursors/base.py index 9119833f..177a124d 100644 --- a/arango/cursor.py +++ b/arango/api/cursors/base.py @@ -5,15 +5,19 @@ CursorNextError, CursorCloseError, ) +from arango.api import APIWrapper +from arango import Request +from arango.jobs import BaseJob -class Cursor(object): +class BaseCursor(APIWrapper): """ArangoDB cursor which returns documents from the server in batches. :param connection: ArangoDB database connection - :type connection: arango.connection.Connection + :type connection: arango.connections.BaseConnection :param init_data: the cursor initialization data :type init_data: dict + :param cursor_type: One of `"cursor"` or `"export"`, which endpoint to use :raises CursorNextError: if the next batch cannot be retrieved :raises CursorCloseError: if the cursor cannot be closed @@ -21,9 +25,10 @@ class Cursor(object): This class is designed to be instantiated internally only. """ - def __init__(self, connection, init_data): - self._conn = connection + def __init__(self, connection, init_data, cursor_type="cursor"): + super(BaseCursor, self).__init__(connection) self._data = init_data + self._cursor_type = cursor_type def __iter__(self): return self @@ -118,55 +123,28 @@ def next(self): :rtype: dict :raises: StopIteration, CursorNextError """ - if not self.batch() and self.has_more(): - res = self._conn.put("/_api/cursor/{}".format(self.id)) - if res.status_code not in HTTP_OK: - raise CursorNextError(res) - self._data = res.body - elif not self.batch() and not self.has_more(): - raise StopIteration - return self.batch().pop(0) - def close(self, ignore_missing=True): - """Close the cursor and free the resources tied to it. + if len(self.batch()) == 0: + if not self.has_more(): + raise StopIteration - :returns: whether the cursor was closed successfully - :rtype: bool - :param ignore_missing: ignore missing cursors - :type ignore_missing: bool - :raises: CursorCloseError - """ - if not self.id: - return False - res = self._conn.delete("/_api/cursor/{}".format(self.id)) - if res.status_code not in HTTP_OK: - if res.status_code == 404 and ignore_missing: - return False - raise CursorCloseError(res) - return True + request = Request( + method="put", + url="/_api/{}/{}".format(self._cursor_type, self.id) + ) + def handler(res): + if res.status_code not in HTTP_OK: + raise CursorNextError(res) + return res.body -class ExportCursor(Cursor): # pragma: no cover - """ArangoDB cursor for export queries only. + job = self.handle_request(request, handler, job_class=BaseJob, + use_underlying=True) - .. note:: - This class is designed to be instantiated internally only. - """ + result = job.result(raise_errors=True) - def next(self): - """Read the next result from the cursor. + self._data = result - :returns: the next item in the cursor - :rtype: dict - :raises: StopIteration, CursorNextError - """ - if not self.batch() and self.has_more(): - res = self._conn.put("/_api/export/{}".format(self.id)) - if res.status_code not in HTTP_OK: - raise CursorNextError(res) - self._data = res.body - elif not self.batch() and not self.has_more(): - raise StopIteration return self.batch().pop(0) def close(self, ignore_missing=True): @@ -178,11 +156,24 @@ def close(self, ignore_missing=True): :type ignore_missing: bool :raises: CursorCloseError """ + if not self.id: return False - res = self._conn.delete("/_api/export/{}".format(self.id)) - if res.status_code not in HTTP_OK: - if res.status_code == 404 and ignore_missing: - return False - raise CursorCloseError(res) - return True + + request = Request( + method="delete", + url="/_api/{}/{}".format(self._cursor_type, self.id) + ) + + def handler(res): + if res.status_code not in HTTP_OK: + if res.status_code == 404 and ignore_missing: + return False + + raise CursorCloseError(res) + + return True + + return self.handle_request(request, handler, job_class=BaseJob, + use_underlying=True).result( + raise_errors=True) diff --git a/arango/api/cursors/export.py b/arango/api/cursors/export.py new file mode 100644 index 00000000..b8b77834 --- /dev/null +++ b/arango/api/cursors/export.py @@ -0,0 +1,13 @@ +from arango.api.cursors import BaseCursor + + +class ExportCursor(BaseCursor): # pragma: no cover + """ArangoDB cursor for export queries only. + + .. note:: + This class is designed to be instantiated internally only. + """ + + def __init__(self, connection, init_data): + super(ExportCursor, self).__init__(connection, init_data, + cursor_type="export") diff --git a/arango/api/databases/__init__.py b/arango/api/databases/__init__.py new file mode 100644 index 00000000..771d6015 --- /dev/null +++ b/arango/api/databases/__init__.py @@ -0,0 +1,2 @@ +from .base import BaseDatabase +from .system import SystemDatabase diff --git a/arango/database.py b/arango/api/databases/base.py similarity index 63% rename from arango/database.py rename to arango/api/databases/base.py index e529b8a1..9d716b82 100644 --- a/arango/database.py +++ b/arango/api/databases/base.py @@ -2,12 +2,15 @@ from datetime import datetime -from requests import ConnectionError - -from arango.async import AsyncExecution -from arango.batch import BatchExecution -from arango.cluster import ClusterTest -from arango.collections.standard import Collection +from arango import Request +from arango.connections.executions import ( + AsyncExecution, + BatchExecution, + ClusterTest, + TransactionExecution +) +from arango.connections import BaseConnection +from arango.api.collections import Collection from arango.utils import HTTP_OK from arango.exceptions import ( AsyncJobClearError, @@ -15,6 +18,7 @@ CollectionCreateError, CollectionDeleteError, CollectionListError, + DatabaseListError, DatabasePropertiesError, DocumentGetError, DocumentRevisionError, @@ -54,13 +58,12 @@ UserReplaceError, UserUpdateError, ) -from arango.graph import Graph -from arango.transaction import Transaction -from arango.aql import AQL -from arango.wal import WriteAheadLog +from arango.api.wrappers import Graph, AQL +from arango.api import APIWrapper +from arango import WriteAheadLog -class Database(object): +class BaseDatabase(APIWrapper): """ArangoDB database. :param connection: ArangoDB database connection @@ -69,16 +72,79 @@ class Database(object): """ def __init__(self, connection): - self._conn = connection + super(BaseDatabase, self).__init__(connection) self._aql = AQL(self._conn) self._wal = WriteAheadLog(self._conn) def __repr__(self): - return ''.format(self._conn.database) + return ''.format(self.name) def __getitem__(self, name): return self.collection(name) + @property + def protocol(self): + """Return the internet transfer protocol. + + :returns: the internet transfer protocol + :rtype: str | unicode + """ + return self._conn.protocol + + @property + def host(self): + """Return the ArangoDB host. + + :returns: the ArangoDB host + :rtype: str | unicode + """ + return self._conn.host + + @property + def port(self): + """Return the ArangoDB port. + + :returns: the ArangoDB port + :rtype: int + """ + return self._conn.port + + @property + def username(self): + """Return the ArangoDB username. + + :returns: the ArangoDB username + :rtype: str | unicode + """ + return self._conn.username + + @property + def password(self): + """Return the ArangoDB user password. + + :returns: the ArangoDB user password + :rtype: str | unicode + """ + return self._conn.password + + @property + def http_client(self): + """Return the HTTP client. + + :returns: the HTTP client + :rtype: arango.http_clients.base.BaseHTTPClient + """ + return self._conn.http_client + + @property + def logging_enabled(self): + """Return True if logging is enabled, False otherwise. + + :returns: whether logging is enabled + :rtype: bool + """ + return self._conn.logging_enabled + @property def connection(self): """Return the connection object. @@ -117,6 +183,59 @@ def wal(self): """ return self._wal + def db(self, name, username=None, password=None): + """Return the database object. + + This is an alias for + :func:`arango.api.databases.BaseDatabase.database`. + + :param name: the name of the database + :type name: str | unicode + :param username: the username for authentication (if set, overrides + the username specified during the client initialization) + :type username: str | unicode + :param password: the password for authentication (if set, overrides + the password specified during the client initialization + :type password: str | unicode + :returns: the database object + :rtype: arango.database.Database + """ + db = self.database(name, username, password) + return db + + def database(self, name, username=None, password=None): + """Return the database object. + + :param name: the name of the database + :type name: str | unicode + :param username: the username for authentication (if set, overrides + the username specified during the client initialization) + :type username: str | unicode + :param password: the password for authentication (if set, overrides + the password specified during the client initialization + :type password: str | unicode + :returns: the database object + :rtype: arango.database.Database + """ + + if username is None: + username = self.username + + if password is None: + password = self.password + + return BaseDatabase(BaseConnection( + protocol=self.protocol, + host=self.host, + port=self.port, + database=name, + username=username, + password=password, + http_client=self.http_client, + enable_logging=self.logging_enabled, + old_behavior=self._conn.old_behavior + )) + def verify(self): """Verify the connection to ArangoDB server. @@ -125,10 +244,19 @@ def verify(self): :raises arango.exceptions.ServerConnectionError: if the connection to the ArangoDB server fails """ - res = self._conn.head('/_api/version') - if res.status_code not in HTTP_OK: - raise ServerConnectionError(res) - return True + + request = Request( + method='head', + url='/_api/version' + ) + + def handler(res): + x = res.status_code + if x not in HTTP_OK: + raise ServerConnectionError(res) + return True + + return self.handle_request(request, handler) def version(self): """Return the version of the ArangoDB server. @@ -138,13 +266,19 @@ def version(self): :raises arango.exceptions.ServerVersionError: if the server version cannot be retrieved """ - res = self._conn.get( - endpoint='/_api/version', + + request = Request( + method='get', + url='/_api/version', params={'details': False} ) - if res.status_code not in HTTP_OK: - raise ServerVersionError(res) - return res.body['version'] + + def handler(res): + if res.status_code not in HTTP_OK: + raise ServerVersionError(res) + return res.body['version'] + + return self.handle_request(request, handler) def details(self): """Return the component details on the ArangoDB server. @@ -154,13 +288,19 @@ def details(self): :raises arango.exceptions.ServerDetailsError: if the server details cannot be retrieved """ - res = self._conn.get( - endpoint='/_api/version', + + request = Request( + method='get', + url='/_api/version', params={'details': True} ) - if res.status_code not in HTTP_OK: - raise ServerDetailsError(res) - return res.body['details'] + + def handler(res): + if res.status_code not in HTTP_OK: + raise ServerDetailsError(res) + return res.body['details'] + + return self.handle_request(request, handler) def required_db_version(self): """Return the required version of the target database. @@ -170,10 +310,46 @@ def required_db_version(self): :raises arango.exceptions.ServerRequiredDBVersionError: if the required database version cannot be retrieved """ - res = self._conn.get('/_admin/database/target-version') - if res.status_code not in HTTP_OK: - raise ServerRequiredDBVersionError(res) - return res.body['version'] + + request = Request( + method='get', + url='/_admin/database/target-version' + ) + + def handler(res): + if res.status_code not in HTTP_OK: + raise ServerRequiredDBVersionError(res) + return res.body['version'] + + return self.handle_request(request, handler) + + def databases(self, user_only=False): + """Return the database names. + + :param user_only: list only the databases accessible by the user + :type user_only: bool + :returns: the database names + :rtype: list + :raises arango.exceptions.DatabaseListError: if the retrieval fails + """ + + # Get the current user's databases + if user_only: + url = '/_api/database/user' + else: + url = '/_api/database' + + request = Request( + method="get", + url=url + ) + + def handler(res): + if res.status_code not in HTTP_OK: + raise DatabaseListError(res) + return res.body['result'] + + return self.handle_request(request, handler) def statistics(self, description=False): """Return the server statistics. @@ -183,15 +359,25 @@ def statistics(self, description=False): :raises arango.exceptions.ServerStatisticsError: if the server statistics cannot be retrieved """ - res = self._conn.get( - '/_admin/statistics-description' - if description else '/_admin/statistics' + + if description: + url = '/_admin/statistics-description' + else: + url = '/_admin/statistics' + + request = Request( + method="get", + url=url ) - if res.status_code not in HTTP_OK: - raise ServerStatisticsError(res) - res.body.pop('code', None) - res.body.pop('error', None) - return res.body + + def handler(res): + if res.status_code not in HTTP_OK: + raise ServerStatisticsError(res) + res.body.pop('code', None) + res.body.pop('error', None) + return res.body + + return self.handle_request(request, handler) def role(self): """Return the role of the server in the cluster if any. @@ -206,10 +392,18 @@ def role(self): :raises arango.exceptions.ServerRoleError: if the server role cannot be retrieved """ - res = self._conn.get('/_admin/server/role') - if res.status_code not in HTTP_OK: - raise ServerRoleError(res) - return res.body.get('role') + + request = Request( + method='get', + url='/_admin/server/role' + ) + + def handler(res): + if res.status_code not in HTTP_OK: + raise ServerRoleError(res) + return res.body.get('role') + + return self.handle_request(request, handler) def time(self): """Return the current server system time. @@ -219,10 +413,18 @@ def time(self): :raises arango.exceptions.ServerTimeError: if the server time cannot be retrieved """ - res = self._conn.get('/_admin/time') - if res.status_code not in HTTP_OK: - raise ServerTimeError(res) - return datetime.fromtimestamp(res.body['time']) + + request = Request( + method='get', + url='/_admin/time' + ) + + def handler(res): + if res.status_code not in HTTP_OK: + raise ServerTimeError(res) + return datetime.fromtimestamp(res.body['time']) + + return self.handle_request(request, handler) def echo(self): """Return information on the last request (headers, payload etc.) @@ -232,10 +434,17 @@ def echo(self): :raises arango.exceptions.ServerEchoError: if the last request cannot be retrieved from the server """ - res = self._conn.get('/_admin/echo') - if res.status_code not in HTTP_OK: - raise ServerEchoError(res) - return res.body + request = Request( + method="get", + url="/_admin/echo" + ) + + def handler(res): + if res.status_code not in HTTP_OK: + raise ServerEchoError(res) + return res.body + + return self.handle_request(request, handler) def sleep(self, seconds): """Suspend the execution for a specified duration before returning. @@ -247,13 +456,18 @@ def sleep(self, seconds): :raises arango.exceptions.ServerSleepError: if the server cannot be suspended """ - res = self._conn.get( - '/_admin/sleep', + request = Request( + method="get", + url="/_admin/sleep", params={'duration': seconds} ) - if res.status_code not in HTTP_OK: - raise ServerSleepError(res) - return res.body['duration'] + + def handler(res): + if res.status_code not in HTTP_OK: + raise ServerSleepError(res) + return res.body['duration'] + + return self.handle_request(request, handler) def shutdown(self): # pragma: no cover """Initiate the server shutdown sequence. @@ -263,13 +477,18 @@ def shutdown(self): # pragma: no cover :raises arango.exceptions.ServerShutdownError: if the server shutdown sequence cannot be initiated """ - try: - res = self._conn.delete('/_admin/shutdown') - except ConnectionError: - return False - if res.status_code not in HTTP_OK: - raise ServerShutdownError(res) - return True + + request = Request( + method="delete", + url="/_admin/shutdown" + ) + + def handler(res): + if res.status_code not in HTTP_OK: + raise ServerShutdownError(res) + return True + + return self.handle_request(request, handler) def run_tests(self, tests): # pragma: no cover """Run the available unittests on the server. @@ -280,10 +499,19 @@ def run_tests(self, tests): # pragma: no cover :rtype: dict :raises arango.exceptions.ServerRunTestsError: if the test suites fail """ - res = self._conn.post('/_admin/test', data={'tests': tests}) - if res.status_code not in HTTP_OK: - raise ServerRunTestsError(res) - return res.body + + request = Request( + method="post", + url="/_admin/test", + data={'tests': tests} + ) + + def handler(res): + if res.status_code not in HTTP_OK: + raise ServerRunTestsError(res) + return res.body + + return self.handle_request(request, handler) def execute(self, program): # pragma: no cover """Execute a Javascript program on the server. @@ -295,10 +523,19 @@ def execute(self, program): # pragma: no cover :raises arango.exceptions.ServerExecuteError: if the program cannot be executed on the server """ - res = self._conn.post('/_admin/execute', data=program) - if res.status_code not in HTTP_OK: - raise ServerExecuteError(res) - return res.body + + request = Request( + method="post", + url="/_admin/execute", + data=program + ) + + def handler(res): + if res.status_code not in HTTP_OK: + raise ServerExecuteError(res) + return res.body + + return self.handle_request(request, handler) def read_log(self, upto=None, @@ -337,6 +574,7 @@ def read_log(self, :raises arango.exceptions.ServerReadLogError: if the server log entries cannot be read """ + params = dict() if upto is not None: params['upto'] = upto @@ -352,12 +590,21 @@ def read_log(self, params['search'] = search if sort is not None: params['sort'] = sort - res = self._conn.get('/_admin/log') - if res.status_code not in HTTP_OK: - raise ServerReadLogError(res) - if 'totalAmount' in res.body: - res.body['total_amount'] = res.body.pop('totalAmount') - return res.body + + request = Request( + method="get", + url="/_admin/log", + params=params + ) + + def handler(res): + if res.status_code not in HTTP_OK: + raise ServerReadLogError(res) + if 'totalAmount' in res.body: + res.body['total_amount'] = res.body.pop('totalAmount') + return res.body + + return self.handle_request(request, handler) def log_levels(self): """Return the current logging levels. @@ -368,10 +615,18 @@ def log_levels(self): .. note:: This method is only compatible with ArangoDB version 3.1+ only. """ - res = self._conn.get('/_admin/log/level') - if res.status_code not in HTTP_OK: - raise ServerLogLevelError(res) - return res.body + + request = Request( + method="get", + url="/_admin/log/level" + ) + + def handler(res): + if res.status_code not in HTTP_OK: + raise ServerLogLevelError(res) + return res.body + + return self.handle_request(request, handler) def set_log_levels(self, **kwargs): """Set the logging levels. @@ -396,10 +651,19 @@ def set_log_levels(self, **kwargs): .. note:: This method is only compatible with ArangoDB version 3.1+ only. """ - res = self._conn.put('/_admin/log/level', data=kwargs) - if res.status_code not in HTTP_OK: - raise ServerLogLevelSetError(res) - return res.body + + request = Request( + method="put", + url="/_admin/log/level", + data=kwargs + ) + + def handler(res): + if res.status_code not in HTTP_OK: + raise ServerLogLevelSetError(res) + return res.body + + return self.handle_request(request, handler) def reload_routing(self): """Reload the routing information from the collection *routing*. @@ -409,10 +673,18 @@ def reload_routing(self): :raises arango.exceptions.ServerReloadRoutingError: if the routing cannot be reloaded """ - res = self._conn.post('/_admin/routing/reload') - if res.status_code not in HTTP_OK: - raise ServerReloadRoutingError(res) - return 'error' not in res.body + + request = Request( + method="post", + url="/_admin/routing/reload" + ) + + def handler(res): + if res.status_code not in HTTP_OK: + raise ServerReloadRoutingError(res) + return 'error' not in res.body + + return self.handle_request(request, handler) def asynchronous(self, return_result=True): """Return the asynchronous request object. @@ -473,7 +745,7 @@ def transaction(self, exiting out of the context :type commit_on_error: bool """ - return Transaction( + return TransactionExecution( connection=self._conn, read=read, write=write, @@ -514,12 +786,20 @@ def properties(self): :raises arango.exceptions.DatabasePropertiesError: if the properties of the database cannot be retrieved """ - res = self._conn.get('/_api/database/current') - if res.status_code not in HTTP_OK: - raise DatabasePropertiesError(res) - result = res.body['result'] - result['system'] = result.pop('isSystem') - return result + + request = Request( + method='get', + url='/_api/database/current' + ) + + def handler(res): + if res.status_code not in HTTP_OK: + raise DatabasePropertiesError(res) + result = res.body['result'] + result['system'] = result.pop('isSystem') + return result + + return self.handle_request(request, handler) def get_document(self, document_id, rev=None, match_rev=True): """Retrieve a document by its ID (collection/key) @@ -540,20 +820,31 @@ def get_document(self, document_id, rev=None, match_rev=True): :raises arango.exceptions.DocumentGetError: if the document cannot be retrieved from the collection """ - res = self._conn.get( - '/_api/document/{}'.format(document_id), - headers=( - {'If-Match' if match_rev else 'If-None-Match': rev} - if rev is not None else {} - ) + + headers = {} + + if rev is not None: + if match_rev: + headers["If-Match"] = rev + else: + headers["If-None-Match"] = rev + + request = Request( + method='get', + url='/_api/document/{}'.format(document_id), + headers=headers ) - if res.status_code in {304, 412}: - raise DocumentRevisionError(res) - elif res.status_code == 404 and res.error_code == 1202: - return None - elif res.status_code in HTTP_OK: - return res.body - raise DocumentGetError(res) + + def handler(res): + if res.status_code in {304, 412}: + raise DocumentRevisionError(res) + elif res.status_code == 404 and res.error_code == 1202: + return None + elif res.status_code in HTTP_OK: + return res.body + raise DocumentGetError(res) + + return self.handle_request(request, handler) ######################### # Collection Management # @@ -567,16 +858,24 @@ def collections(self): :raises arango.exceptions.CollectionListError: if the list of collections cannot be retrieved """ - res = self._conn.get('/_api/collection') - if res.status_code not in HTTP_OK: - raise CollectionListError(res) - return [{ - 'id': col['id'], - 'name': col['name'], - 'system': col['isSystem'], - 'type': Collection.TYPES[col['type']], - 'status': Collection.STATUSES[col['status']], - } for col in map(dict, res.body['result'])] + + request = Request( + method='get', + url='/_api/collection' + ) + + def handler(res): + if res.status_code not in HTTP_OK: + raise CollectionListError(res) + return [{ + 'id': col['id'], + 'name': col['name'], + 'system': col['isSystem'], + 'type': Collection.TYPES[col['type']], + 'status': Collection.STATUSES[col['status']], + } for col in map(dict, res.body['result'])] + + return self.handle_request(request, handler) def collection(self, name): """Return the collection object. @@ -666,6 +965,7 @@ def create_collection(self, :raises arango.exceptions.CollectionCreateError: if the collection cannot be created in the database """ + key_options = {'type': key_generator, 'allowUserKeys': user_keys} if key_increment is not None: key_options['increment'] = key_increment @@ -678,9 +978,14 @@ def create_collection(self, 'doCompact': compact, 'isSystem': system, 'isVolatile': volatile, - 'type': 3 if edge else 2, 'keyOptions': key_options } + + if edge: + data["type"] = 3 + else: + data["type"] = 2 + if journal_size is not None: data['journalSize'] = journal_size if shard_count is not None: @@ -692,10 +997,18 @@ def create_collection(self, if replication_factor is not None: data['replicationFactor'] = replication_factor - res = self._conn.post('/_api/collection', data=data) - if res.status_code not in HTTP_OK: - raise CollectionCreateError(res) - return self.collection(name) + request = Request( + method='post', + url='/_api/collection', + data=data + ) + + def handler(res): + if res.status_code not in HTTP_OK: + raise CollectionCreateError(res) + return self.collection(name) + + return self.handle_request(request, handler) def delete_collection(self, name, ignore_missing=False, system=None): """Delete a collection. @@ -713,15 +1026,25 @@ def delete_collection(self, name, ignore_missing=False, system=None): :raises arango.exceptions.CollectionDeleteError: if the collection cannot be deleted from the database """ - res = self._conn.delete( - '/_api/collection/{}'.format(name), - params={'isSystem': system} - if system is not None else None # pragma: no cover + + params = {} + + if system is not None: + params['isSystem'] = system + + request = Request( + method='delete', + url='/_api/collection/{}'.format(name), + params=params ) - if res.status_code not in HTTP_OK: - if not (res.status_code == 404 and ignore_missing): - raise CollectionDeleteError(res) - return not res.body['error'] + + def handler(res): + if res.status_code not in HTTP_OK: + if not (res.status_code == 404 and ignore_missing): + raise CollectionDeleteError(res) + return not res.body['error'] + + return self.handle_request(request, handler) #################### # Graph Management # @@ -735,21 +1058,29 @@ def graphs(self): :raises arango.exceptions.GraphListError: if the list of graphs cannot be retrieved """ - res = self._conn.get('/_api/gharial') - if res.status_code not in HTTP_OK: - raise GraphListError(res) - return [ - { - 'name': record['_key'], - 'revision': record['_rev'], - 'edge_definitions': record['edgeDefinitions'], - 'orphan_collections': record['orphanCollections'], - 'smart': record.get('isSmart'), - 'smart_field': record.get('smartGraphAttribute'), - 'shard_count': record.get('numberOfShards') - } for record in map(dict, res.body['graphs']) - ] + request = Request( + method='get', + url='/_api/gharial' + ) + + def handler(res): + if res.status_code not in HTTP_OK: + raise GraphListError(res) + + return [ + { + 'name': record['_key'], + 'revision': record['_rev'], + 'edge_definitions': record['edgeDefinitions'], + 'orphan_collections': record['orphanCollections'], + 'smart': record.get('isSmart'), + 'smart_field': record.get('smartGraphAttribute'), + 'shard_count': record.get('numberOfShards') + } for record in map(dict, res.body['graphs']) + ] + + return self.handle_request(request, handler) def graph(self, name): """Return the graph object. @@ -804,6 +1135,7 @@ def create_graph(self, :raises arango.exceptions.GraphCreateError: if the graph cannot be created in the database """ + data = {'name': name} if edge_definitions is not None: data['edgeDefinitions'] = [{ @@ -820,10 +1152,18 @@ def create_graph(self, if shard_count is not None: # pragma: no cover data['numberOfShards'] = shard_count - res = self._conn.post('/_api/gharial', data=data) - if res.status_code not in HTTP_OK: - raise GraphCreateError(res) - return Graph(self._conn, name) + request = Request( + method='post', + url='/_api/gharial', + data=data + ) + + def handler(res): + if res.status_code not in HTTP_OK: + raise GraphCreateError(res) + return Graph(self._conn, name) + + return self.handle_request(request, handler) def delete_graph(self, name, ignore_missing=False, drop_collections=None): """Drop the graph of the given name from the database. @@ -842,18 +1182,25 @@ def delete_graph(self, name, ignore_missing=False, drop_collections=None): :raises arango.exceptions.GraphDeleteError: if the graph cannot be deleted from the database """ + params = {} + if drop_collections is not None: params['dropCollections'] = drop_collections - res = self._conn.delete( - '/_api/gharial/{}'.format(name), + request = Request( + method='delete', + url='/_api/gharial/{}'.format(name), params=params ) - if res.status_code not in HTTP_OK: - if not (res.status_code == 404 and ignore_missing): - raise GraphDeleteError(res) - return not res.body['error'] + + def handler(res): + if res.status_code not in HTTP_OK: + if not (res.status_code == 404 and ignore_missing): + raise GraphDeleteError(res) + return not res.body['error'] + + return self.handle_request(request, handler) ################### # Task Management # @@ -867,10 +1214,18 @@ def tasks(self): :raises arango.exceptions.TaskListError: if the list of active server tasks cannot be retrieved from the server """ - res = self._conn.get('/_api/tasks') - if res.status_code not in HTTP_OK: - raise TaskListError(res) - return res.body + + request = Request( + method='get', + url='/_api/tasks' + ) + + def handler(res): + if res.status_code not in HTTP_OK: + raise TaskListError(res) + return res.body + + return self.handle_request(request, handler) def task(self, task_id): """Return the active server task with the given id. @@ -882,12 +1237,20 @@ def task(self, task_id): :raises arango.exceptions.TaskGetError: if the task cannot be retrieved from the server """ - res = self._conn.get('/_api/tasks/{}'.format(task_id)) - if res.status_code not in HTTP_OK: - raise TaskGetError(res) - res.body.pop('code', None) - res.body.pop('error', None) - return res.body + + request = Request( + method='get', + url='/_api/tasks/{}'.format(task_id) + ) + + def handler(res): + if res.status_code not in HTTP_OK: + raise TaskGetError(res) + res.body.pop('code', None) + res.body.pop('error', None) + return res.body + + return self.handle_request(request, handler) # TODO verify which arguments are optional def create_task(self, @@ -920,24 +1283,34 @@ def create_task(self, """ data = { 'name': name, - 'command': command, - 'params': params if params else {}, + 'command': command } + if params is not None: + data["params"] = params if task_id is not None: data['id'] = task_id if period is not None: data['period'] = period if offset is not None: data['offset'] = offset - res = self._conn.post( - '/_api/tasks/{}'.format(task_id if task_id else ''), + + if task_id is None: + task_id = "" + + request = Request( + method='post', + url='/_api/tasks/{}'.format(task_id), data=data ) - if res.status_code not in HTTP_OK: - raise TaskCreateError(res) - res.body.pop('code', None) - res.body.pop('error', None) - return res.body + + def handler(res): + if res.status_code not in HTTP_OK: + raise TaskCreateError(res) + res.body.pop('code', None) + res.body.pop('error', None) + return res.body + + return self.handle_request(request, handler) def delete_task(self, task_id, ignore_missing=False): """Delete the server task specified by ID. @@ -951,17 +1324,24 @@ def delete_task(self, task_id, ignore_missing=False): :raises arango.exceptions.TaskDeleteError: when the task cannot be deleted from the server """ - res = self._conn.delete('/_api/tasks/{}'.format(task_id)) - if res.status_code not in HTTP_OK: - if not (res.status_code == 404 and ignore_missing): - raise TaskDeleteError(res) - return not res.body['error'] + + request = Request( + method='delete', + url='/_api/tasks/{}'.format(task_id) + ) + + def handler(res): + if res.status_code not in HTTP_OK: + if not (res.status_code == 404 and ignore_missing): + raise TaskDeleteError(res) + return not res.body['error'] + + return self.handle_request(request, handler) ################### # User Management # ################### - # noinspection PyTypeChecker def users(self): """Return the details of all users. @@ -969,14 +1349,22 @@ def users(self): :rtype: [dict] :raises arango.exceptions.UserListError: if the retrieval fails """ - res = self._conn.get('/_api/user') - if res.status_code not in HTTP_OK: - raise UserListError(res) - return [{ - 'username': record['user'], - 'active': record['active'], - 'extra': record['extra'], - } for record in res.body['result']] + + request = Request( + method="get", + url="/_api/user" + ) + + def handler(res): + if res.status_code not in HTTP_OK: + raise UserListError(res) + return [{ + 'username': record['user'], + 'active': record['active'], + 'extra': record['extra'], + } for record in res.body['result']] + + return self.handle_request(request, handler) def user(self, username): """Return the details of a user. @@ -987,14 +1375,22 @@ def user(self, username): :rtype: dict :raises arango.exceptions.UserGetError: if the retrieval fails """ - res = self._conn.get('/_api/user/{}'.format(username)) - if res.status_code not in HTTP_OK: - raise UserGetError(res) - return { - 'username': res.body['user'], - 'active': res.body['active'], - 'extra': res.body['extra'] - } + + request = Request( + method="get", + url='/_api/user/{}'.format(username) + ) + + def handler(res): + if res.status_code not in HTTP_OK: + raise UserGetError(res) + return { + 'username': res.body['user'], + 'active': res.body['active'], + 'extra': res.body['extra'] + } + + return self.handle_request(request, handler) def create_user(self, username, password, active=None, extra=None): """Create a new user. @@ -1011,20 +1407,29 @@ def create_user(self, username, password, active=None, extra=None): :rtype: dict :raises arango.exceptions.UserCreateError: if the user create fails """ + data = {'user': username, 'passwd': password} if active is not None: data['active'] = active if extra is not None: data['extra'] = extra - res = self._conn.post('/_api/user', data=data) - if res.status_code not in HTTP_OK: - raise UserCreateError(res) - return { - 'username': res.body['user'], - 'active': res.body['active'], - 'extra': res.body['extra'], - } + request = Request( + method="post", + url="/_api/user", + data=data + ) + + def handler(res): + if res.status_code not in HTTP_OK: + raise UserCreateError(res) + return { + 'username': res.body['user'], + 'active': res.body['active'], + 'extra': res.body['extra'], + } + + return self.handle_request(request, handler) def update_user(self, username, password=None, active=None, extra=None): """Update an existing user. @@ -1041,6 +1446,7 @@ def update_user(self, username, password=None, active=None, extra=None): :rtype: dict :raises arango.exceptions.UserUpdateError: if the user update fails """ + data = {} if password is not None: data['passwd'] = password @@ -1049,17 +1455,22 @@ def update_user(self, username, password=None, active=None, extra=None): if extra is not None: data['extra'] = extra - res = self._conn.patch( - '/_api/user/{user}'.format(user=username), + request = Request( + method="patch", + url='/_api/user/{user}'.format(user=username), data=data ) - if res.status_code not in HTTP_OK: - raise UserUpdateError(res) - return { - 'username': res.body['user'], - 'active': res.body['active'], - 'extra': res.body['extra'], - } + + def handler(res): + if res.status_code not in HTTP_OK: + raise UserUpdateError(res) + return { + 'username': res.body['user'], + 'active': res.body['active'], + 'extra': res.body['extra'], + } + + return self.handle_request(request, handler) def replace_user(self, username, password, active=None, extra=None): """Replace an existing user. @@ -1082,17 +1493,22 @@ def replace_user(self, username, password, active=None, extra=None): if extra is not None: data['extra'] = extra - res = self._conn.put( - '/_api/user/{user}'.format(user=username), + request = Request( + method="put", + url='/_api/user/{user}'.format(user=username), data=data ) - if res.status_code not in HTTP_OK: - raise UserReplaceError(res) - return { - 'username': res.body['user'], - 'active': res.body['active'], - 'extra': res.body['extra'], - } + + def handler(res): + if res.status_code not in HTTP_OK: + raise UserReplaceError(res) + return { + 'username': res.body['user'], + 'active': res.body['active'], + 'extra': res.body['extra'], + } + + return self.handle_request(request, handler) def delete_user(self, username, ignore_missing=False): """Delete an existing user. @@ -1106,31 +1522,48 @@ def delete_user(self, username, ignore_missing=False): :rtype: bool :raises arango.exceptions.UserDeleteError: if the user delete fails """ - res = self._conn.delete('/_api/user/{user}'.format(user=username)) - if res.status_code in HTTP_OK: - return True - elif res.status_code == 404 and ignore_missing: - return False - raise UserDeleteError(res) - def user_access(self, username): - """Return a user's access details for the database. + request = Request( + method="delete", + url='/_api/user/{user}'.format(user=username) + ) - Appropriate permissions are required in order to execute this method. + def handler(res): + if res.status_code in HTTP_OK: + return True + elif res.status_code == 404 and ignore_missing: + return False + raise UserDeleteError(res) + + return self.handle_request(request, handler) + + def user_access(self, username, full=False): + """Return a user's access details for databases (and collections). :param username: The name of the user. :type username: str | unicode - :returns: The access details (e.g. ``"rw"``, ``None``) - :rtype: str | unicode | None + :param full: Return the full set of access levels for all databases and + collections for the user. + :type full: bool + :returns: The names of the databases (and collections) the user has + access to, with their access levels + :rtype: dict TODO CHANGED return structure due to mismatch with + return from database :raises: arango.exceptions.UserAccessError: If the retrieval fails. """ - res = self._conn.get( - '/_api/user/{}/database/{}'.format(username, self.name), + + request = Request( + method="get", + url='/_api/user/{}/database'.format(username), + params={'full': full} ) - if res.status_code in HTTP_OK: - result = res.body['result'].lower() - return None if result == 'none' else result - raise UserAccessError(res) + + def handler(res): + if res.status_code in HTTP_OK: + return res.body['result'] + raise UserAccessError(res) + + return self.handle_request(request, handler) def grant_user_access(self, username, database=None): """Grant user access to the database. @@ -1146,16 +1579,22 @@ def grant_user_access(self, username, database=None): :rtype: bool :raises arango.exceptions.UserGrantAccessError: If the operation fails. """ + if database is None: database = self.name - res = self._conn.put( - '/_api/user/{}/database/{}'.format(username, database), + request = Request( + method="put", + url='/_api/user/{}/database/{}'.format(username, database), data={'grant': 'rw'} ) - if res.status_code in HTTP_OK: - return True - raise UserGrantAccessError(res) + + def handler(res): + if res.status_code in HTTP_OK: + return True + raise UserGrantAccessError(res) + + return self.handle_request(request, handler) def revoke_user_access(self, username, database=None): """Revoke user access to the database. @@ -1174,12 +1613,17 @@ def revoke_user_access(self, username, database=None): if database is None: database = self.name - res = self._conn.delete( - '/_api/user/{}/database/{}'.format(username, database) + request = Request( + method="delete", + url='/_api/user/{}/database/{}'.format(username, database) ) - if res.status_code in HTTP_OK: - return True - raise UserRevokeAccessError(res) + + def handler(res): + if res.status_code in HTTP_OK: + return True + raise UserRevokeAccessError(res) + + return self.handle_request(request, handler) ######################## # Async Job Management # @@ -1196,13 +1640,24 @@ def async_jobs(self, status, count=None): :rtype: [str] :raises arango.exceptions.AsyncJobListError: If the retrieval fails. """ - res = self._conn.get( - '/_api/job/{}'.format(status), - params={} if count is None else {'count': count} + + params = {} + + if count is not None: + params["count"] = count + + request = Request( + method="get", + url='/_api/job/{}'.format(status), + params=params ) - if res.status_code not in HTTP_OK: - raise AsyncJobListError(res) - return res.body + + def handler(res): + if res.status_code not in HTTP_OK: + raise AsyncJobListError(res) + return res.body + + return self.handle_request(request, handler) def clear_async_jobs(self, threshold=None): """Delete asynchronous job results from the server. @@ -1218,16 +1673,26 @@ def clear_async_jobs(self, threshold=None): .. note:: Async jobs currently queued or running are not stopped. """ + if threshold is None: - res = self._conn.delete('/_api/job/all') + url = "/_api/job/all" + params = None else: - res = self._conn.delete( - '/_api/job/expired', - params={'stamp': threshold} - ) - if res.status_code in HTTP_OK: - return True - raise AsyncJobClearError(res) + url = "/_api/job/expired" + params = {'stamp': threshold} + + request = Request( + method="delete", + url=url, + params=params + ) + + def handler(res): + if res.status_code in HTTP_OK: + return True + raise AsyncJobClearError(res) + + return self.handle_request(request, handler) ############### # Pregel Jobs # @@ -1273,11 +1738,15 @@ def create_pregel_job(self, :raises arango.exceptions.PregelJobCreateError: If the operation fails. """ + data = { 'algorithm': algorithm, 'graphName': graph, } - algorithm_params = algorithm_params or {} + + if algorithm_params is None: + algorithm_params = {} + if store is not None: algorithm_params['store'] = store if max_gss is not None: @@ -1291,13 +1760,18 @@ def create_pregel_job(self, if algorithm_params: data['params'] = algorithm_params - res = self._conn.post( - '/_api/control_pregel', + request = Request( + method='post', + url='/_api/control_pregel', data=data ) - if res.status_code in HTTP_OK: - return res.body - raise PregelJobCreateError(res) + + def handler(res): + if res.status_code in HTTP_OK: + return res.body + raise PregelJobCreateError(res) + + return self.handle_request(request, handler) def pregel_job(self, job_id): """Return the details of a Pregel job. @@ -1308,10 +1782,16 @@ def pregel_job(self, job_id): :rtype: dict :raises arango.exceptions.PregelJobGetError: If the lookup fails. """ - res = self._conn.get( - '/_api/control_pregel/{}'.format(job_id) + + request = Request( + method='get', + url='/_api/control_pregel/{}'.format(job_id) ) - if res.status_code in HTTP_OK: + + def handler(res): + if res.status_code not in HTTP_OK: + raise PregelJobGetError(res) + if 'edgeCount' in res.body: res.body['edge_count'] = res.body.pop('edgeCount') if 'receivedCount' in res.body: @@ -1323,7 +1803,8 @@ def pregel_job(self, job_id): if 'vertexCount' in res.body: res.body['vertex_count'] = res.body.pop('vertexCount') return res.body - raise PregelJobGetError(res) + + return self.handle_request(request, handler) def delete_pregel_job(self, job_id): """Cancel/delete a Pregel job. @@ -1334,9 +1815,15 @@ def delete_pregel_job(self, job_id): :rtype: bool :raises arango.exceptions.PregelJobDeleteError: If the deletion fails. """ - res = self._conn.delete( - '/_api/control_pregel/{}'.format(job_id) + + request = Request( + method='delete', + url='/_api/control_pregel/{}'.format(job_id) ) - if res.status_code in HTTP_OK: + + def handler(res): + if res.status_code not in HTTP_OK: + raise PregelJobDeleteError(res) return True - raise PregelJobDeleteError(res) + + return self.handle_request(request, handler) diff --git a/arango/api/databases/system.py b/arango/api/databases/system.py new file mode 100644 index 00000000..19c0a990 --- /dev/null +++ b/arango/api/databases/system.py @@ -0,0 +1,140 @@ +from __future__ import absolute_import, unicode_literals + +from arango import Request +from arango.utils import HTTP_OK +from arango.api.databases import BaseDatabase +from arango.exceptions import ( + DatabaseCreateError, + DatabaseDeleteError, + ServerEndpointsError +) + + +class SystemDatabase(BaseDatabase): + """ArangoDB System Database. Every call made using this database must + use root access. + """ + + def __init__(self, + connection): + + if connection.database != "_system": + raise ValueError("SystemDatabase must recieve a connection to " + "the _system database.") + + super(SystemDatabase, self).__init__(connection) + + def endpoints(self): + """Return the list of the endpoints the server is listening on. + + Each endpoint is mapped to a list of databases. If the list is empty, + it means all databases can be accessed via the endpoint. If the list + contains more than one database, the first database receives all the + requests by default, unless the name is explicitly specified. + + :returns: the list of endpoints + :rtype: list + :raises arango.exceptions.ServerEndpointsError: if the endpoints + cannot be retrieved from the server + """ + + request = Request( + method="get", + url="/_api/endpoint" + ) + + def handler(res): + if res.status_code not in HTTP_OK: + raise ServerEndpointsError(res) + return res.body + + return self.handle_request(request, handler) + + def create_database(self, name, users=None, username=None, password=None): + """Create a new database. + + :param name: the name of the new database + :type name: str | unicode + :param users: the list of users with access to the new database, where + each user is a dictionary with keys ``"username"``, ``"password"``, + ``"active"`` and ``"extra"``. + :type users: [dict] + :param username: the username for authentication (if set, overrides + the username specified during the client initialization) + :type username: str | unicode + :param password: the password for authentication (if set, overrides + the password specified during the client initialization + :type password: str | unicode + :returns: the database object + :rtype: arango.database.Database + :raises arango.exceptions.DatabaseCreateError: if the create fails + + .. note:: + Here is an example entry in **users**: + + .. code-block:: python + + { + 'username': 'john', + 'password': 'password', + 'active': True, + 'extra': {'Department': 'IT'} + } + + If **users** is not set, only the root and the current user are + granted access to the new database by default. + """ + + data = { + 'name': name, + } + + if users is not None: + data['users'] = [{ + 'username': user['username'], + 'passwd': user['password'], + 'active': user.get('active', True), + 'extra': user.get('extra', {}) + } for user in users] + + request = Request( + method="post", + url="/_api/database", + data=data + ) + + def handler(res): + if res.status_code not in HTTP_OK: + raise DatabaseCreateError(res) + return self.db(name, username, password) + + return self.handle_request(request, handler) + + def delete_database(self, name, ignore_missing=False): + """Delete the database of the specified name. + + :param name: the name of the database to delete + :type name: str | unicode + :param ignore_missing: ignore missing databases + :type ignore_missing: bool + :returns: whether the database was deleted successfully + :rtype: bool + :raises arango.exceptions.DatabaseDeleteError: if the delete fails + + .. note:: + Root privileges (i.e. access to the ``_system`` database) are + required to use this method. + """ + + request = Request( + method="delete", + url='/_api/database/{}'.format(name) + ) + + def handler(res): + if res.status_code not in HTTP_OK: + if not (res.status_code == 404 and ignore_missing): + raise DatabaseDeleteError(res) + return not res.body['error'] + + return self.handle_request(request, handler) diff --git a/arango/wal.py b/arango/api/wal.py similarity index 58% rename from arango/wal.py rename to arango/api/wal.py index 94ea1e34..3714d22a 100644 --- a/arango/wal.py +++ b/arango/api/wal.py @@ -1,5 +1,6 @@ from __future__ import absolute_import, unicode_literals +from arango import Request from arango.utils import HTTP_OK from arango.exceptions import ( WALFlushError, @@ -7,9 +8,10 @@ WALConfigureError, WALTransactionListError ) +from arango.api import APIWrapper -class WriteAheadLog(object): +class WriteAheadLog(APIWrapper): """ArangoDB write-ahead log object. :param connection: ArangoDB database connection @@ -20,7 +22,7 @@ class WriteAheadLog(object): """ def __init__(self, connection): - self._conn = connection + APIWrapper.__init__(self, connection) def __repr__(self): return "" @@ -33,18 +35,27 @@ def properties(self): :raises arango.exceptions.WALPropertiesError: if the WAL properties cannot be retrieved from the server """ - res = self._conn.get('/_admin/wal/properties') - if res.status_code not in HTTP_OK: - raise WALPropertiesError(res) - return { - 'oversized_ops': res.body.get('allowOversizeEntries'), - 'log_size': res.body.get('logfileSize'), - 'historic_logs': res.body.get('historicLogfiles'), - 'reserve_logs': res.body.get('reserveLogfiles'), - 'sync_interval': res.body.get('syncInterval'), - 'throttle_wait': res.body.get('throttleWait'), - 'throttle_limit': res.body.get('throttleWhenPending') - } + request = Request( + method='get', + url='/_admin/wal/properties' + ) + + def handler(res): + if res.status_code not in HTTP_OK: + raise WALPropertiesError(res) + return { + 'oversized_ops': res.body.get('allowOversizeEntries'), + 'log_size': res.body.get('logfileSize'), + 'historic_logs': res.body.get('historicLogfiles'), + 'reserve_logs': res.body.get('reserveLogfiles'), + 'sync_interval': res.body.get('syncInterval'), + 'throttle_wait': res.body.get('throttleWait'), + 'throttle_limit': res.body.get('throttleWhenPending') + } + + response = self.handle_request(request, handler) + + return response def configure(self, oversized_ops=None, log_size=None, historic_logs=None, reserve_logs=None, throttle_wait=None, throttle_limit=None): @@ -80,18 +91,29 @@ def configure(self, oversized_ops=None, log_size=None, historic_logs=None, data['throttleWait'] = throttle_wait if throttle_limit is not None: data['throttleWhenPending'] = throttle_limit - res = self._conn.put('/_admin/wal/properties', data=data) - if res.status_code not in HTTP_OK: - raise WALConfigureError(res) - return { - 'oversized_ops': res.body.get('allowOversizeEntries'), - 'log_size': res.body.get('logfileSize'), - 'historic_logs': res.body.get('historicLogfiles'), - 'reserve_logs': res.body.get('reserveLogfiles'), - 'sync_interval': res.body.get('syncInterval'), - 'throttle_wait': res.body.get('throttleWait'), - 'throttle_limit': res.body.get('throttleWhenPending') - } + + request = Request( + method='put', + url='/_admin/wal/properties', + data=data + ) + + def handler(res): + if res.status_code not in HTTP_OK: + raise WALConfigureError(res) + return { + 'oversized_ops': res.body.get('allowOversizeEntries'), + 'log_size': res.body.get('logfileSize'), + 'historic_logs': res.body.get('historicLogfiles'), + 'reserve_logs': res.body.get('reserveLogfiles'), + 'sync_interval': res.body.get('syncInterval'), + 'throttle_wait': res.body.get('throttleWait'), + 'throttle_limit': res.body.get('throttleWhenPending') + } + + response = self.handle_request(request, handler) + + return response def transactions(self): """Return details on currently running transactions. @@ -113,14 +135,24 @@ def transactions(self): :raises arango.exceptions.WALTransactionListError: if the details on the transactions cannot be retrieved """ - res = self._conn.get('/_admin/wal/transactions') - if res.status_code not in HTTP_OK: - raise WALTransactionListError(res) - return { - 'last_collected': res.body['minLastCollected'], - 'last_sealed': res.body['minLastSealed'], - 'count': res.body['runningTransactions'] - } + + request = Request( + method='get', + url='/_admin/wal/transactions' + ) + + def handler(res): + if res.status_code not in HTTP_OK: + raise WALTransactionListError(res) + return { + 'last_collected': res.body['minLastCollected'], + 'last_sealed': res.body['minLastSealed'], + 'count': res.body['runningTransactions'] + } + + response = self.handle_request(request, handler) + + return response def flush(self, sync=True, garbage_collect=True): """Flush the write-ahead log to collection journals and data files. @@ -134,13 +166,22 @@ def flush(self, sync=True, garbage_collect=True): :raises arango.exceptions.WALFlushError: it the WAL cannot be flushed """ - res = self._conn.put( - '/_admin/wal/flush', - data={ - 'waitForSync': sync, - 'waitForCollector': garbage_collect - } + data = { + 'waitForSync': sync, + 'waitForCollector': garbage_collect + } + + request = Request( + method='put', + url='/_admin/wal/flush', + data=data ) - if res.status_code not in HTTP_OK: - raise WALFlushError(res) - return not res.body.get('error') + + def handler(res): + if res.status_code not in HTTP_OK: + raise WALFlushError(res) + return not res.body.get('error') + + response = self.handle_request(request, handler) + + return response diff --git a/arango/api/wrappers/__init__.py b/arango/api/wrappers/__init__.py new file mode 100644 index 00000000..5603f841 --- /dev/null +++ b/arango/api/wrappers/__init__.py @@ -0,0 +1,2 @@ +from .aql import AQL +from .graph import Graph diff --git a/arango/aql.py b/arango/api/wrappers/aql.py similarity index 92% rename from arango/aql.py rename to arango/api/wrappers/aql.py index e6ae5f04..a702b576 100644 --- a/arango/aql.py +++ b/arango/api/wrappers/aql.py @@ -1,7 +1,7 @@ from __future__ import absolute_import, unicode_literals -from arango.utils import HTTP_OK -from arango.cursor import Cursor +from arango.api import APIWrapper +from arango.api.cursors.base import BaseCursor from arango.exceptions import ( AQLQueryExplainError, AQLQueryValidateError, @@ -14,8 +14,7 @@ AQLCachePropertiesError ) from arango.request import Request - -from arango.api import APIWrapper +from arango.utils import HTTP_OK class AQL(APIWrapper): @@ -66,14 +65,18 @@ def explain(self, query, all_plans=False, max_plans=None, opt_rules=None): request = Request( method='post', - endpoint='/_api/explain', + url='/_api/explain', data={'query': query, 'options': options} ) def handler(res): if res.status_code not in HTTP_OK: raise AQLQueryExplainError(res) - return res.body['plan' if 'plan' in res.body else 'plans'] + + if "plan" in res.body: + return res.body["plan"] + else: + return res.body["plans"] return self.handle_request(request, handler) @@ -89,7 +92,7 @@ def validate(self, query): """ request = Request( method='post', - endpoint='/_api/query', + url='/_api/query', data={'query': query} ) @@ -123,7 +126,7 @@ def execute(self, query, count=False, batch_size=None, ttl=None, :param optimizer_rules: list of optimizer rules :type optimizer_rules: list :returns: document cursor - :rtype: arango.cursor.Cursor + :rtype: arango.cursor.BaseCursor :raises arango.exceptions.AQLQueryExecuteError: if the query cannot be executed :raises arango.exceptions.CursorCloseError: if the cursor cannot be @@ -149,14 +152,14 @@ def execute(self, query, count=False, batch_size=None, ttl=None, request = Request( method='post', - endpoint='/_api/cursor', + url='/_api/cursor', data=data ) def handler(res): if res.status_code not in HTTP_OK: raise AQLQueryExecuteError(res) - return Cursor(self._conn, res.body) + return BaseCursor(self._conn, res.body) return self.handle_request(request, handler) @@ -168,7 +171,7 @@ def functions(self): :raises arango.exceptions.AQLFunctionListError: if the AQL functions cannot be retrieved """ - request = Request(method='get', endpoint='/_api/aqlfunction') + request = Request(method='get', url='/_api/aqlfunction') def handler(res): if res.status_code not in HTTP_OK: @@ -192,7 +195,7 @@ def create_function(self, name, code): """ request = Request( method='post', - endpoint='/_api/aqlfunction', + url='/_api/aqlfunction', data={'name': name, 'code': code} ) @@ -223,10 +226,16 @@ def delete_function(self, name, group=None, ignore_missing=False): :raises arango.exceptions.AQLFunctionDeleteError: if the AQL function cannot be deleted """ + + params = {} + + if group is not None: + params["group"] = group + request = Request( method='delete', - endpoint='/_api/aqlfunction/{}'.format(name), - params={'group': group} if group is not None else {} + url='/_api/aqlfunction/{}'.format(name), + params=params ) def handler(res): @@ -255,7 +264,7 @@ def properties(self): """ request = Request( method='get', - endpoint='/_api/query-cache/properties' + url='/_api/query-cache/properties' ) def handler(res): @@ -285,7 +294,7 @@ def configure(self, mode=None, limit=None): request = Request( method='put', - endpoint='/_api/query-cache/properties', + url='/_api/query-cache/properties', data=data ) @@ -304,7 +313,7 @@ def clear(self): :raises arango.exceptions.AQLCacheClearError: if the cache query cannot be cleared """ - request = Request(method='delete', endpoint='/_api/query-cache') + request = Request(method='delete', url='/_api/query-cache') def handler(res): if res.status_code not in HTTP_OK: diff --git a/arango/graph.py b/arango/api/wrappers/graph.py similarity index 95% rename from arango/graph.py rename to arango/api/wrappers/graph.py index 93bf213e..76b8df7d 100644 --- a/arango/graph.py +++ b/arango/api/wrappers/graph.py @@ -1,7 +1,7 @@ from __future__ import absolute_import, unicode_literals -from arango.collections.edge import EdgeCollection -from arango.collections.vertex import VertexCollection +from arango.api.collections import EdgeCollection +from arango.api.collections import VertexCollection from arango.utils import HTTP_OK from arango.exceptions import ( EdgeDefinitionCreateError, @@ -76,7 +76,7 @@ def properties(self): """ request = Request( method='get', - endpoint='/_api/gharial/{}'.format(self._name) + url='/_api/gharial/{}'.format(self._name) ) def handler(res): @@ -117,7 +117,7 @@ def orphan_collections(self): """ request = Request( method='get', - endpoint='/_api/gharial/{}'.format(self._name) + url='/_api/gharial/{}'.format(self._name) ) def handler(res): @@ -137,7 +137,7 @@ def vertex_collections(self): """ request = Request( method='get', - endpoint='/_api/gharial/{}/vertex'.format(self._name) + url='/_api/gharial/{}/vertex'.format(self._name) ) def handler(res): @@ -159,7 +159,7 @@ def create_vertex_collection(self, name): """ request = Request( method='post', - endpoint='/_api/gharial/{}/vertex'.format(self._name), + url='/_api/gharial/{}/vertex'.format(self._name), data={'collection': name} ) @@ -184,7 +184,7 @@ def delete_vertex_collection(self, name, purge=False): """ request = Request( method='delete', - endpoint='/_api/gharial/{}/vertex/{}'.format(self._name, name), + url='/_api/gharial/{}/vertex/{}'.format(self._name, name), params={'dropCollection': purge} ) @@ -209,7 +209,7 @@ def edge_definitions(self): """ request = Request( method='get', - endpoint='/_api/gharial/{}'.format(self._name) + url='/_api/gharial/{}'.format(self._name) ) def handler(res): @@ -246,7 +246,7 @@ def create_edge_definition(self, name, from_collections, to_collections): """ request = Request( method='post', - endpoint='/_api/gharial/{}/edge'.format(self._name), + url='/_api/gharial/{}/edge'.format(self._name), data={ 'collection': name, 'from': from_collections, @@ -277,7 +277,7 @@ def replace_edge_definition(self, name, from_collections, to_collections): """ request = Request( method='put', - endpoint='/_api/gharial/{}/edge/{}'.format( + url='/_api/gharial/{}/edge/{}'.format( self._name, name ), data={ @@ -308,7 +308,7 @@ def delete_edge_definition(self, name, purge=False): """ request = Request( method='delete', - endpoint='/_api/gharial/{}/edge/{}'.format(self._name, name), + url='/_api/gharial/{}/edge/{}'.format(self._name, name), params={'dropCollection': purge} ) @@ -426,7 +426,7 @@ def traverse(self, } request = Request( method='post', - endpoint='/_api/traversal', + url='/_api/traversal', data={k: v for k, v in data.items() if v is not None} ) diff --git a/arango/async.py b/arango/async.py deleted file mode 100644 index 75739d5d..00000000 --- a/arango/async.py +++ /dev/null @@ -1,240 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -from arango.collections.standard import Collection -from arango.connection import Connection -from arango.utils import HTTP_OK -from arango.exceptions import ( - AsyncExecuteError, - AsyncJobCancelError, - AsyncJobStatusError, - AsyncJobResultError, - AsyncJobClearError -) -from arango.graph import Graph -from arango.aql import AQL - - -class AsyncExecution(Connection): - """ArangoDB asynchronous execution. - - API requests via this class are placed in a server-side in-memory task - queue and executed asynchronously in a fire-and-forget style. - - :param connection: ArangoDB database connection - :type connection: arango.connection.Connection - :param return_result: if ``True``, an :class:`arango.async.AsyncJob` - instance (which holds the result of the request) is returned each - time an API request is queued, otherwise ``None`` is returned - :type return_result: bool - - .. warning:: - Asynchronous execution is currently an experimental feature and is not - thread-safe. - """ - - def __init__(self, connection, return_result=True): - super(AsyncExecution, self).__init__( - protocol=connection.protocol, - host=connection.host, - port=connection.port, - username=connection.username, - password=connection.password, - http_client=connection.http_client, - database=connection.database, - enable_logging=connection.logging_enabled - ) - self._return_result = return_result - self._aql = AQL(self) - self._type = 'async' - - def __repr__(self): - return '' - - def handle_request(self, request, handler): - """Handle the incoming request and response handler. - - :param request: the API request to be placed in the server-side queue - :type request: arango.request.Request - :param handler: the response handler - :type handler: callable - :returns: the async job or None - :rtype: arango.async.AsyncJob - :raises arango.exceptions.AsyncExecuteError: if the async request - cannot be executed - """ - if self._return_result: - request.headers['x-arango-async'] = 'store' - else: - request.headers['x-arango-async'] = 'true' - - res = getattr(self, request.method)(**request.kwargs) - if res.status_code not in HTTP_OK: - raise AsyncExecuteError(res) - if self._return_result: - return AsyncJob(self, res.headers['x-arango-async-id'], handler) - - @property - def aql(self): - """Return the AQL object tailored for asynchronous execution. - - API requests via the returned query object are placed in a server-side - in-memory task queue and executed asynchronously in a fire-and-forget - style. - - :returns: ArangoDB query object - :rtype: arango.query.AQL - """ - return self._aql - - def collection(self, name): - """Return a collection object tailored for asynchronous execution. - - API requests via the returned collection object are placed in a - server-side in-memory task queue and executed asynchronously in - a fire-and-forget style. - - :param name: the name of the collection - :type name: str | unicode - :returns: the collection object - :rtype: arango.collections.Collection - """ - return Collection(self, name) - - def graph(self, name): - """Return a graph object tailored for asynchronous execution. - - API requests via the returned graph object are placed in a server-side - in-memory task queue and executed asynchronously in a fire-and-forget - style. - - :param name: the name of the graph - :type name: str | unicode - :returns: the graph object - :rtype: arango.graph.Graph - """ - return Graph(self, name) - - -class AsyncJob(object): - """ArangoDB async job which holds the result of an API request. - - An async job tracks the status of a queued API request and its result. - - :param connection: ArangoDB database connection - :type connection: arango.connection.Connection - :param job_id: the ID of the async job - :type job_id: str | unicode - :param handler: the response handler - :type handler: callable - """ - - def __init__(self, connection, job_id, handler): - self._conn = connection - self._id = job_id - self._handler = handler - - def __repr__(self): - return ''.format(self._id) - - @property - def id(self): - """Return the ID of the async job. - - :returns: the ID of the async job - :rtype: str | unicode - """ - return self._id - - def status(self): - """Return the status of the async job from the server. - - :returns: the status of the async job, which can be ``"pending"`` (the - job is still in the queue), ``"done"`` (the job finished or raised - an exception) - :rtype: str | unicode - :raises arango.exceptions.AsyncJobStatusError: if the status of the - async job cannot be retrieved from the server - """ - res = self._conn.get('/_api/job/{}'.format(self.id)) - if res.status_code == 204: - return 'pending' - elif res.status_code in HTTP_OK: - return 'done' - elif res.status_code == 404: - raise AsyncJobStatusError(res, 'Job {} missing'.format(self.id)) - else: - raise AsyncJobStatusError(res) - - def result(self): - """Return the result of the async job if available. - - :returns: the result or the exception from the async job - :rtype: object - :raises arango.exceptions.AsyncJobResultError: if the result of the - async job cannot be retrieved from the server - - .. note:: - An async job result will automatically be cleared from the server - once fetched and will *not* be available in subsequent calls. - """ - res = self._conn.put('/_api/job/{}'.format(self._id)) - if ('X-Arango-Async-Id' in res.headers - or 'x-arango-async-id' in res.headers): - try: - result = self._handler(res) - except Exception as error: - return error - else: - return result - elif res.status_code == 204: - raise AsyncJobResultError(res, 'Job {} not done'.format(self._id)) - elif res.status_code == 404: - raise AsyncJobResultError(res, 'Job {} missing'.format(self._id)) - else: - raise AsyncJobResultError(res) - - def cancel(self, ignore_missing=False): # pragma: no cover - """Cancel the async job if it is still pending. - - :param ignore_missing: ignore missing async jobs - :type ignore_missing: bool - :returns: ``True`` if the job was cancelled successfully, ``False`` if - the job was not found but **ignore_missing** was set to ``True`` - :rtype: bool - :raises arango.exceptions.AsyncJobCancelError: if the async job cannot - be cancelled - - .. note:: - An async job cannot be cancelled once it is taken out of the queue - (i.e. started, finished or cancelled). - """ - res = self._conn.put('/_api/job/{}/cancel'.format(self._id)) - if res.status_code == 200: - return True - elif res.status_code == 404: - if ignore_missing: - return False - raise AsyncJobCancelError(res, 'Job {} missing'.format(self._id)) - else: - raise AsyncJobCancelError(res) - - def clear(self, ignore_missing=False): - """Delete the result of the job from the server. - - :param ignore_missing: ignore missing async jobs - :type ignore_missing: bool - :returns: ``True`` if the result was deleted successfully, ``False`` - if the job was not found but **ignore_missing** was set to ``True`` - :rtype: bool - :raises arango.exceptions.AsyncJobClearError: if the result of the - async job cannot be delete from the server - """ - res = self._conn.delete('/_api/job/{}'.format(self._id)) - if res.status_code in HTTP_OK: - return True - elif res.status_code == 404: - if ignore_missing: - return False - raise AsyncJobClearError(res, 'Job {} missing'.format(self._id)) - else: - raise AsyncJobClearError(res) diff --git a/arango/client.py b/arango/client.py index 6856728a..f9274576 100644 --- a/arango/client.py +++ b/arango/client.py @@ -1,79 +1,38 @@ -from __future__ import absolute_import, unicode_literals - -from datetime import datetime - -from requests import ConnectionError - +from arango.api.databases import SystemDatabase from arango.http_clients import DefaultHTTPClient -from arango.connection import Connection -from arango.utils import HTTP_OK -from arango.database import Database -from arango.exceptions import ( - AsyncJobClearError, - AsyncJobListError, - DatabaseCreateError, - DatabaseDeleteError, - DatabaseListError, - ServerConnectionError, - ServerDetailsError, - ServerEchoError, - ServerEndpointsError, - ServerExecuteError, - ServerLogLevelError, - ServerLogLevelSetError, - ServerReadLogError, - ServerReloadRoutingError, - ServerRequiredDBVersionError, - ServerRoleError, - ServerRunTestsError, - ServerShutdownError, - ServerSleepError, - ServerStatisticsError, - ServerTimeError, - ServerVersionError, - UserCreateError, - UserDeleteError, - UserGetError, - UserGrantAccessError, - UserListError, - UserRevokeAccessError, - UserUpdateError, - UserReplaceError, - UserAccessError -) -from arango.wal import WriteAheadLog +from arango.connections import BaseConnection -class ArangoClient(object): - """ArangoDB client. +class ArangoClient(SystemDatabase): + """ArangoDB Client. - :param protocol: The internet transfer protocol (default: ``"http"``). - :type protocol: str | unicode - :param host: ArangoDB server host (default: ``"localhost"``). - :type host: str | unicode - :param port: ArangoDB server port (default: ``8529``). - :type port: int or str - :param username: ArangoDB default username (default: ``"root"``). - :type username: str | unicode - :param password: ArangoDB default password (default: ``""``). - :param verify: Check the connection during initialization. Root privileges - are required to use this flag. - :type verify: bool - :param http_client: Custom HTTP client to override the default one with. - Please refer to the API documentation for more details. - :type http_client: arango.http_clients.base.BaseHTTPClient - :param enable_logging: Log all API requests as debug messages. - :type enable_logging: bool - :param check_cert: Verify SSL certificate when making HTTP requests. This - flag is ignored if a custom **http_client** is specified. - :type check_cert: bool - :param use_session: Use session when making HTTP requests. This flag is - ignored if a custom **http_client** is specified. - :type use_session: bool - :param logger: Custom logger to record the API requests with. The logger's - ``debug`` method is called. - :type logger: logging.Logger - """ + :param protocol: The internet transfer protocol (default: ``"http"``). + :type protocol: str | unicode + :param host: ArangoDB server host (default: ``"localhost"``). + :type host: str | unicode + :param port: ArangoDB server port (default: ``8529``). + :type port: int or str + :param username: ArangoDB default username (default: ``"root"``). + :type username: str | unicode + :param password: ArangoDB default password (default: ``""``). + :param verify: Check the connection during initialization. Root + privileges are required to use this flag. + :type verify: bool + :param http_client: Custom HTTP client to override the default one + with. Please refer to the API documentation for more details. + :type http_client: arango.http_clients.base.BaseHTTPClient + :param enable_logging: Log all API requests as debug messages. + :type enable_logging: bool + :param check_cert: Verify SSL certificate when making HTTP requests. + This flag is ignored if a custom **http_client** is specified. + :type check_cert: bool + :param use_session: Use session when making HTTP requests. This flag is + ignored if a custom **http_client** is specified. + :type use_session: bool + :param logger: Custom logger to record the API requests with. The + logger's ``debug`` method is called. + :type logger: logging.Logger + """ def __init__(self, protocol='http', @@ -86,962 +45,32 @@ def __init__(self, enable_logging=True, check_cert=True, use_session=True, - logger=None): + logger=None, + old_behavior=True): + + if http_client is None: + http_client = DefaultHTTPClient( + use_session=use_session, + check_cert=check_cert + ) - self._protocol = protocol - self._host = host - self._port = port - self._username = username - self._password = password - self._http_client = DefaultHTTPClient( - use_session=use_session, - check_cert=check_cert - ) if http_client is None else http_client - self._logging_enabled = enable_logging - self._conn = Connection( - protocol=self._protocol, - host=self._host, - port=self._port, + conn = BaseConnection( + protocol=protocol, + host=host, + port=port, database='_system', - username=self._username, - password=self._password, - http_client=self._http_client, - enable_logging=self._logging_enabled, - logger=logger + username=username, + password=password, + http_client=http_client, + enable_logging=enable_logging, + logger=logger, + old_behavior=old_behavior ) - self._wal = WriteAheadLog(self._conn) + + super(ArangoClient, self).__init__(conn) if verify: self.verify() def __repr__(self): - return ''.format(self._host) - - def verify(self): - """Verify the connection to ArangoDB server. - - :returns: ``True`` if the connection is successful - :rtype: bool - :raises arango.exceptions.ServerConnectionError: if the connection to - the ArangoDB server fails - - .. note:: - Only the root user can access this method. For non-root users, - use :func:`arango.database.Database.verify` (via a database - the users have access to) instead. - """ - res = self._conn.head('/_api/version') - if res.status_code not in HTTP_OK: - raise ServerConnectionError(res) - return True - - @property - def protocol(self): - """Return the internet transfer protocol. - - :returns: the internet transfer protocol - :rtype: str | unicode - """ - return self._protocol - - @property - def host(self): - """Return the ArangoDB host. - - :returns: the ArangoDB host - :rtype: str | unicode - """ - return self._host - - @property - def port(self): - """Return the ArangoDB port. - - :returns: the ArangoDB port - :rtype: int - """ - return self._port - - @property - def username(self): - """Return the ArangoDB username. - - :returns: the ArangoDB username - :rtype: str | unicode - """ - return self._username - - @property - def password(self): - """Return the ArangoDB user password. - - :returns: the ArangoDB user password - :rtype: str | unicode - """ - return self._password - - @property - def http_client(self): - """Return the HTTP client. - - :returns: the HTTP client - :rtype: arango.http_clients.base.BaseHTTPClient - """ - return self._http_client - - @property - def logging_enabled(self): - """Return True if logging is enabled, False otherwise. - - :returns: whether logging is enabled - :rtype: bool - """ - return self._logging_enabled - - @property - def wal(self): - """Return the write-ahead log object. - - :returns: the write-ahead log object - :rtype: arango.wal.WriteAheadLog - """ - return self._wal - - def version(self): - """Return the version of the ArangoDB server. - - :returns: the server version - :rtype: str | unicode - :raises arango.exceptions.ServerVersionError: if the server version - cannot be retrieved - - .. note:: - Only the root user can access this method. For non-root users, - use :func:`arango.database.Database.version` (via a database - the users have access to) instead. - """ - res = self._conn.get( - endpoint='/_api/version', - params={'details': False} - ) - if res.status_code not in HTTP_OK: - raise ServerVersionError(res) - return res.body['version'] - - def details(self): - """Return the component details on the ArangoDB server. - - :returns: the server details - :rtype: dict - :raises arango.exceptions.ServerDetailsError: if the server details - cannot be retrieved - - .. note:: - Only the root user can access this method. For non-root users, - use :func:`arango.database.Database.details` (via a database - the users have access to) instead. - """ - res = self._conn.get( - endpoint='/_api/version', - params={'details': True} - ) - if res.status_code not in HTTP_OK: - raise ServerDetailsError(res) - return res.body['details'] - - def required_db_version(self): - """Return the required version of the target database. - - :returns: the required version of the target database - :rtype: str | unicode - :raises arango.exceptions.ServerRequiredDBVersionError: if the - required database version cannot be retrieved - - .. note:: - Only the root user can access this method. For non-root users, - use :func:`arango.database.Database.required_db_version` (via - a database the users have access to) instead. - """ - res = self._conn.get('/_admin/database/target-version') - if res.status_code not in HTTP_OK: - raise ServerRequiredDBVersionError(res) - return res.body['version'] - - def statistics(self, description=False): - """Return the server statistics. - - :returns: the statistics information - :rtype: dict - :raises arango.exceptions.ServerStatisticsError: if the server - statistics cannot be retrieved - - .. note:: - Only the root user can access this method. For non-root users, - use :func:`arango.database.Database.statistics` (via a database - the users have access to) instead. - """ - res = self._conn.get( - '/_admin/statistics-description' - if description else '/_admin/statistics' - ) - if res.status_code not in HTTP_OK: - raise ServerStatisticsError(res) - res.body.pop('code', None) - res.body.pop('error', None) - return res.body - - def role(self): - """Return the role of the server in the cluster if any. - - :returns: the server role which can be ``"SINGLE"`` (the server is not - in a cluster), ``"COORDINATOR"`` (the server is a coordinator in - the cluster), ``"PRIMARY"`` (the server is a primary database in - the cluster), ``"SECONDARY"`` (the server is a secondary database - in the cluster) or ``"UNDEFINED"`` (the server role is undefined, - the only possible value for a single server) - :rtype: str | unicode - :raises arango.exceptions.ServerRoleError: if the server role cannot - be retrieved - - .. note:: - Only the root user can access this method. For non-root users, - use :func:`arango.database.Database.role` (via a database the - users have access to) instead. - """ - res = self._conn.get('/_admin/server/role') - if res.status_code not in HTTP_OK: - raise ServerRoleError(res) - return res.body.get('role') - - def time(self): - """Return the current server system time. - - :returns: the server system time - :rtype: datetime.datetime - :raises arango.exceptions.ServerTimeError: if the server time - cannot be retrieved - - .. note:: - Only the root user can access this method. For non-root users, - use :func:`arango.database.Database.time` (via a database the - users have access to) instead. - """ - res = self._conn.get('/_admin/time') - if res.status_code not in HTTP_OK: - raise ServerTimeError(res) - return datetime.fromtimestamp(res.body['time']) - - def endpoints(self): - """Return the list of the endpoints the server is listening on. - - Each endpoint is mapped to a list of databases. If the list is empty, - it means all databases can be accessed via the endpoint. If the list - contains more than one database, the first database receives all the - requests by default, unless the name is explicitly specified. - - :returns: the list of endpoints - :rtype: list - :raises arango.exceptions.ServerEndpointsError: if the endpoints - cannot be retrieved from the server - - .. note:: - Only the root user can access this method. - """ - res = self._conn.get('/_api/endpoint') - if res.status_code not in HTTP_OK: - raise ServerEndpointsError(res) - return res.body - - def echo(self): - """Return information on the last request (headers, payload etc.) - - :returns: the details of the last request - :rtype: dict - :raises arango.exceptions.ServerEchoError: if the last request cannot - be retrieved from the server - - .. note:: - Only the root user can access this method. For non-root users, - use :func:`arango.database.Database.echo` (via a database the - users have access to) instead. - """ - res = self._conn.get('/_admin/echo') - if res.status_code not in HTTP_OK: - raise ServerEchoError(res) - return res.body - - def sleep(self, seconds): - """Suspend the execution for a specified duration before returning. - - :param seconds: the number of seconds to suspend - :type seconds: int - :returns: the number of seconds suspended - :rtype: int - :raises arango.exceptions.ServerSleepError: if the server cannot be - suspended - - .. note:: - Only the root user can access this method. For non-root users, - use :func:`arango.database.Database.sleep` (via a database the - users have access to) instead. - """ - res = self._conn.get( - '/_admin/sleep', - params={'duration': seconds} - ) - if res.status_code not in HTTP_OK: - raise ServerSleepError(res) - return res.body['duration'] - - def shutdown(self): # pragma: no cover - """Initiate the server shutdown sequence. - - :returns: whether the server was shutdown successfully - :rtype: bool - :raises arango.exceptions.ServerShutdownError: if the server shutdown - sequence cannot be initiated - - .. note:: - Only the root user can access this method. For non-root users, - use :func:`arango.database.Database.shutdown` (via a database - the users have access to) instead. - """ - try: - res = self._conn.delete('/_admin/shutdown') - except ConnectionError: - return False - if res.status_code not in HTTP_OK: - raise ServerShutdownError(res) - return True - - def run_tests(self, tests): # pragma: no cover - """Run the available unittests on the server. - - :param tests: list of files containing the test suites - :type tests: list - :returns: the test results - :rtype: dict - :raises arango.exceptions.ServerRunTestsError: if the test suites fail - - .. note:: - Only the root user can access this method. For non-root users, - use :func:`arango.database.Database.run_tests` (via a database - the users have access to) instead. - """ - res = self._conn.post('/_admin/test', data={'tests': tests}) - if res.status_code not in HTTP_OK: - raise ServerRunTestsError(res) - return res.body - - def execute(self, program): # pragma: no cover - """Execute a Javascript program on the server. - - :param program: the body of the Javascript program to execute. - :type program: str | unicode - :returns: the result of the execution - :rtype: str | unicode - :raises arango.exceptions.ServerExecuteError: if the program cannot - be executed on the server - - .. note:: - Only the root user can access this method. For non-root users, - use :func:`arango.database.Database.execute` (via a database - the users have access to) instead. - """ - res = self._conn.post('/_admin/execute', data=program) - if res.status_code not in HTTP_OK: - raise ServerExecuteError(res) - return res.body - - def read_log(self, - upto=None, - level=None, - start=None, - size=None, - offset=None, - search=None, - sort=None): - """Read the global log from the server. - - :param upto: return the log entries up to the given level (mutually - exclusive with argument **level**), which must be ``"fatal"``, - ``"error"``, ``"warning"``, ``"info"`` (default) or ``"debug"`` - :type upto: str | unicode | int - :param level: return the log entries of only the given level (mutually - exclusive with **upto**), which must be ``"fatal"``, ``"error"``, - ``"warning"``, ``"info"`` (default) or ``"debug"`` - :type level: str | unicode | int - :param start: return the log entries whose ID is greater or equal to - the given value - :type start: int - :param size: restrict the size of the result to the given value (this - setting can be used for pagination) - :type size: int - :param offset: the number of entries to skip initially (this setting - can be setting can be used for pagination) - :type offset: int - :param search: return only the log entries containing the given text - :type search: str | unicode - :param sort: sort the log entries according to the given fashion, which - can be ``"sort"`` or ``"desc"`` - :type sort: str | unicode - :returns: the server log entries - :rtype: dict - :raises arango.exceptions.ServerReadLogError: if the server log entries - cannot be read - - .. note:: - Only the root user can access this method. For non-root users, - use :func:`arango.database.Database.read_log` (via a database - the users have access to) instead. - """ - params = dict() - if upto is not None: - params['upto'] = upto - if level is not None: - params['level'] = level - if start is not None: - params['start'] = start - if size is not None: - params['size'] = size - if offset is not None: - params['offset'] = offset - if search is not None: - params['search'] = search - if sort is not None: - params['sort'] = sort - res = self._conn.get('/_admin/log') - if res.status_code not in HTTP_OK: - raise ServerReadLogError(res) - if 'totalAmount' in res.body: - res.body['total_amount'] = res.body.pop('totalAmount') - return res.body - - def log_levels(self): - """Return the current logging levels. - - .. note:: - This method is only compatible with ArangoDB version 3.1+ only. - - :return: the current logging levels - :rtype: dict - - .. note:: - Only the root user can access this method. For non-root users, - use :func:`arango.database.Database.log_levels` (via a database - the users have access to) instead. - """ - res = self._conn.get('/_admin/log/level') - if res.status_code not in HTTP_OK: - raise ServerLogLevelError(res) - return res.body - - def set_log_levels(self, **kwargs): - """Set the logging levels. - - This method takes arbitrary keyword arguments where the keys are the - logger names and the values are the logging levels. For example: - - .. code-block:: python - - arango.set_log_level( - agency='DEBUG', - collector='INFO', - threads='WARNING' - ) - - :return: the new logging levels - :rtype: dict - - .. note:: - Keys that are not valid logger names are simply ignored. - - .. note:: - This method is only compatible with ArangoDB version 3.1+ only. - - .. note:: - Only the root user can access this method. For non-root users, - use :func:`arango.database.Database.set_log_levels` (via a - database the users have access to) instead. - """ - res = self._conn.put('/_admin/log/level', data=kwargs) - if res.status_code not in HTTP_OK: - raise ServerLogLevelSetError(res) - return res.body - - def reload_routing(self): - """Reload the routing information from the collection *routing*. - - :returns: whether the routing was reloaded successfully - :rtype: bool - :raises arango.exceptions.ServerReloadRoutingError: if the routing - cannot be reloaded - - .. note:: - Only the root user can access this method. For non-root users, - use :func:`arango.database.Database.reload_routing` (via a - database the users have access to) instead. - """ - res = self._conn.post('/_admin/routing/reload') - if res.status_code not in HTTP_OK: - raise ServerReloadRoutingError(res) - return 'error' not in res.body - - ####################### - # Database Management # - ####################### - - def databases(self, user_only=False): - """Return the database names. - - :param user_only: list only the databases accessible by the user - :type user_only: bool - :returns: the database names - :rtype: list - :raises arango.exceptions.DatabaseListError: if the retrieval fails - - .. note:: - Only the root user can access this method. - """ - # Get the current user's databases - res = self._conn.get( - '/_api/database/user' - if user_only else '/_api/database' - ) - if res.status_code not in HTTP_OK: - raise DatabaseListError(res) - return res.body['result'] - - def db(self, name, username=None, password=None): - """Return the database object. - - This is an alias for :func:`arango.client.ArangoClient.database`. - - :param name: the name of the database - :type name: str | unicode - :param username: the username for authentication (if set, overrides - the username specified during the client initialization) - :type username: str | unicode - :param password: the password for authentication (if set, overrides - the password specified during the client initialization - :type password: str | unicode - :returns: the database object - :rtype: arango.database.Database - """ - return self.database(name, username, password) - - def database(self, name, username=None, password=None): - """Return the database object. - - :param name: the name of the database - :type name: str | unicode - :param username: the username for authentication (if set, overrides - the username specified during the client initialization) - :type username: str | unicode - :param password: the password for authentication (if set, overrides - the password specified during the client initialization - :type password: str | unicode - :returns: the database object - :rtype: arango.database.Database - """ - return Database(Connection( - protocol=self._protocol, - host=self._host, - port=self._port, - database=name, - username=username or self._username, - password=password or self._password, - http_client=self._http_client, - enable_logging=self._logging_enabled - )) - - def create_database(self, name, users=None, username=None, password=None): - """Create a new database. - - :param name: the name of the new database - :type name: str | unicode - :param users: the list of users with access to the new database, where - each user is a dictionary with keys ``"username"``, ``"password"``, - ``"active"`` and ``"extra"``. - :type users: [dict] - :param username: the username for authentication (if set, overrides - the username specified during the client initialization) - :type username: str | unicode - :param password: the password for authentication (if set, overrides - the password specified during the client initialization - :type password: str | unicode - :returns: the database object - :rtype: arango.database.Database - :raises arango.exceptions.DatabaseCreateError: if the create fails - - .. note:: - Here is an example entry in **users**: - - .. code-block:: python - - { - 'username': 'john', - 'password': 'password', - 'active': True, - 'extra': {'Department': 'IT'} - } - - If **users** is not set, only the root and the current user are - granted access to the new database by default. - - .. note:: - Root privileges (i.e. access to the ``_system`` database) are - required to use this method. - """ - res = self._conn.post( - '/_api/database', - data={ - 'name': name, - 'users': [{ - 'username': user['username'], - 'passwd': user['password'], - 'active': user.get('active', True), - 'extra': user.get('extra', {}) - } for user in users] - } if users else {'name': name} - ) - if res.status_code not in HTTP_OK: - raise DatabaseCreateError(res) - return self.db(name, username, password) - - def delete_database(self, name, ignore_missing=False): - """Delete the database of the specified name. - - :param name: the name of the database to delete - :type name: str | unicode - :param ignore_missing: ignore missing databases - :type ignore_missing: bool - :returns: whether the database was deleted successfully - :rtype: bool - :raises arango.exceptions.DatabaseDeleteError: if the delete fails - - .. note:: - Root privileges (i.e. access to the ``_system`` database) are - required to use this method. - """ - res = self._conn.delete('/_api/database/{}'.format(name)) - if res.status_code not in HTTP_OK: - if not (res.status_code == 404 and ignore_missing): - raise DatabaseDeleteError(res) - return not res.body['error'] - - ################### - # User Management # - ################### - - def users(self): - """Return the details of all users. - - :returns: the details of all users - :rtype: [dict] - :raises arango.exceptions.UserListError: if the retrieval fails - - .. note:: - Only the root user can access this method. For non-root users, - use :func:`arango.database.Database.users` (via a database the - users have access to) instead. - """ - res = self._conn.get('/_api/user') - if res.status_code not in HTTP_OK: - raise UserListError(res) - return [{ - 'username': record['user'], - 'active': record['active'], - 'extra': record['extra'], - } for record in res.body['result']] - - def user(self, username): - """Return the details of a user. - - :param username: the details of the user - :type username: str | unicode - :returns: the user details - :rtype: dict - :raises arango.exceptions.UserGetError: if the retrieval fails - - .. note:: - Only the root user can access this method. For non-root users, - use :func:`arango.database.Database.user` (via a database the - users have access to) instead. - """ - res = self._conn.get('/_api/user/{}'.format(username)) - if res.status_code not in HTTP_OK: - raise UserGetError(res) - return { - 'username': res.body['user'], - 'active': res.body['active'], - 'extra': res.body['extra'] - } - - def create_user(self, username, password, active=None, extra=None): - """Create a new user. - - :param username: the name of the user - :type username: str | unicode - :param password: the user's password - :type password: str | unicode - :param active: whether the user is active - :type active: bool - :param extra: any extra data on the user - :type extra: dict - :returns: the details of the new user - :rtype: dict - :raises arango.exceptions.UserCreateError: if the user create fails - - .. note:: - Only the root user can access this method. For non-root users, - use :func:`arango.database.Database.create_user` (via a database - the users have access to) instead. - """ - data = {'user': username, 'passwd': password} - if active is not None: - data['active'] = active - if extra is not None: - data['extra'] = extra - - res = self._conn.post('/_api/user', data=data) - if res.status_code not in HTTP_OK: - raise UserCreateError(res) - return { - 'username': res.body['user'], - 'active': res.body['active'], - 'extra': res.body['extra'], - } - - def update_user(self, username, password=None, active=None, extra=None): - """Update an existing user. - - :param username: the name of the existing user - :type username: str | unicode - :param password: the user's new password - :type password: str | unicode - :param active: whether the user is active - :type active: bool - :param extra: any extra data on the user - :type extra: dict - :returns: the details of the updated user - :rtype: dict - :raises arango.exceptions.UserUpdateError: if the user update fails - - .. note:: - Only the root user can access this method. For non-root users, - use :func:`arango.database.Database.update_user` (via a database - the users have access to) instead. - """ - data = {} - if password is not None: - data['passwd'] = password - if active is not None: - data['active'] = active - if extra is not None: - data['extra'] = extra - - res = self._conn.patch( - '/_api/user/{user}'.format(user=username), - data=data - ) - if res.status_code not in HTTP_OK: - raise UserUpdateError(res) - return { - 'username': res.body['user'], - 'active': res.body['active'], - 'extra': res.body['extra'], - } - - def replace_user(self, username, password, active=None, extra=None): - """Replace an existing user. - - :param username: the name of the existing user - :type username: str | unicode - :param password: the user's new password - :type password: str | unicode - :param active: whether the user is active - :type active: bool - :param extra: any extra data on the user - :type extra: dict - :returns: the details of the replaced user - :rtype: dict - :raises arango.exceptions.UserReplaceError: if the user replace fails - - .. note:: - Only the root user can access this method. For non-root users, - use :func:`arango.database.Database.replace_user` (via a database - the users have access to) instead. - """ - data = {'user': username, 'passwd': password} - if active is not None: - data['active'] = active - if extra is not None: - data['extra'] = extra - - res = self._conn.put( - '/_api/user/{user}'.format(user=username), - data=data - ) - if res.status_code not in HTTP_OK: - raise UserReplaceError(res) - return { - 'username': res.body['user'], - 'active': res.body['active'], - 'extra': res.body['extra'], - } - - def delete_user(self, username, ignore_missing=False): - """Delete an existing user. - - :param username: the name of the existing user - :type username: str | unicode - :param ignore_missing: ignore missing users - :type ignore_missing: bool - :returns: ``True`` if the operation was successful, ``False`` if the - user was missing but **ignore_missing** was set to ``True`` - :rtype: bool - :raises arango.exceptions.UserDeleteError: if the user delete fails - - .. note:: - Only the root user can access this method. For non-root users, - use :func:`arango.database.Database.delete_user` (via a database - the users have access to) instead. - """ - res = self._conn.delete('/_api/user/{user}'.format(user=username)) - if res.status_code in HTTP_OK: - return True - elif res.status_code == 404 and ignore_missing: - return False - raise UserDeleteError(res) - - def user_access(self, username, full=False): - """Return a user's access details for databases (and collections). - - :param username: The name of the user. - :type username: str | unicode - :param full: Return the full set of access levels for all databases and - collections for the user. - :type full: bool - :returns: The names of the databases (and collections) the user has - access to. - :rtype: [str] | [unicode] - :raises: arango.exceptions.UserAccessError: If the retrieval fails. - - .. note:: - Only the root user can access this method. For non-root users, - use :func:`arango.database.Database.user_access` (via a database - the users have access to) instead. - """ - res = self._conn.get( - '/_api/user/{}/database'.format(username), - params={'full': full} - ) - if res.status_code in HTTP_OK: - return list(res.body['result']) - raise UserAccessError(res) - - def grant_user_access(self, username, database): - """Grant user access to a database. - - :param username: The name of the user. - :type username: str | unicode - :param database: The name of the database. - :type database: str | unicode - :returns: Whether the operation was successful or not. - :rtype: bool - :raises arango.exceptions.UserGrantAccessError: If the operation fails. - - .. note:: - Only the root user can access this method. For non-root users, - use :func:`arango.database.Database.grant_user_access` (via a - database the users have access to) instead. - """ - res = self._conn.put( - '/_api/user/{}/database/{}'.format(username, database), - data={'grant': 'rw'} - ) - if res.status_code in HTTP_OK: - return True - raise UserGrantAccessError(res) - - def revoke_user_access(self, username, database): - """Revoke user access to a database. - - :param username: The name of the user. - :type username: str | unicode - :param database: The name of the database. - :type database: str | unicode | unicode - :returns: Whether the operation was successful or not. - :rtype: bool - :raises arango.exceptions.UserRevokeAccessError: If operation fails. - - .. note:: - Only the root user can access this method. For non-root users, - use :func:`arango.database.Database.revoke_user_access` (via a - database the users have access to) instead. - """ - res = self._conn.delete( - '/_api/user/{}/database/{}'.format(username, database) - ) - if res.status_code in HTTP_OK: - return True - raise UserRevokeAccessError(res) - - ######################## - # Async Job Management # - ######################## - - def async_jobs(self, status, count=None): - """Return the IDs of asynchronous jobs with the specified status. - - :param status: The job status (``"pending"`` or ``"done"``). - :type status: str | unicode - :param count: The maximum number of job IDs to return. - :type count: int - :returns: The list of job IDs. - :rtype: [str] - :raises arango.exceptions.AsyncJobListError: If the retrieval fails. - - .. note:: - Only the root user can access this method. For non-root users, - use :func:`arango.database.Database.async_jobs` (via a database - the users have access to) instead. - """ - res = self._conn.get( - '/_api/job/{}'.format(status), - params={} if count is None else {'count': count} - ) - if res.status_code not in HTTP_OK: - raise AsyncJobListError(res) - return res.body - - def clear_async_jobs(self, threshold=None): - """Delete asynchronous job results from the server. - - :param threshold: If specified, only the job results created prior to - the threshold (a unix timestamp) are deleted, otherwise *all* job - results are deleted. - :type threshold: int - :returns: Whether the deletion of results was successful. - :rtype: bool - :raises arango.exceptions.AsyncJobClearError: If the operation fails. - - .. note:: - Async jobs currently queued or running are not stopped. - - .. note:: - Only the root user can access this method. For non-root users, - use :func:`arango.database.Database.clear_async_jobs` (via a - database the users have access to) instead. - """ - if threshold is None: - res = self._conn.delete('/_api/job/all') - else: - res = self._conn.delete( - '/_api/job/expired', - params={'stamp': threshold} - ) - if res.status_code in HTTP_OK: - return True - raise AsyncJobClearError(res) + return ''.format(self.connection.host) diff --git a/arango/collections/__init__.py b/arango/collections/__init__.py deleted file mode 100644 index aee9ba9a..00000000 --- a/arango/collections/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from arango.collections.edge import EdgeCollection # noqa: F401 -from arango.collections.standard import Collection # noqa: F401 -from arango.collections.vertex import VertexCollection # noqa: F401 diff --git a/arango/connection.py b/arango/connection.py deleted file mode 100644 index bab42154..00000000 --- a/arango/connection.py +++ /dev/null @@ -1,314 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -import logging - -from arango.http_clients import DefaultHTTPClient -from arango.utils import sanitize - - -class Connection(object): - """ArangoDB database connection. - - :param protocol: the internet transfer protocol (default: ``"http"``) - :type protocol: str | unicode - :param host: ArangoDB host (default: ``"localhost"``) - :type host: str | unicode - :param port: ArangoDB port (default: ``8529``) - :type port: int | str | unicode - :param database: the name of the target database (default: ``"_system"``) - :type database: str | unicode - :param username: ArangoDB username (default: ``"root"``) - :type username: str | unicode - :param password: ArangoDB password (default: ``""``) - :type password: str | unicode - :param http_client: the HTTP client - :type http_client: arango.clients.base.BaseHTTPClient - :param enable_logging: log all API requests with a logger named "arango" - :type enable_logging: bool - """ - - def __init__(self, - protocol='http', - host='localhost', - port=8529, - database='_system', - username='root', - password='', - http_client=None, - enable_logging=True, - logger=None): - - self._protocol = protocol.strip('/') - self._host = host.strip('/') - self._port = port - self._database = database or '_system' - self._url_prefix = '{protocol}://{host}:{port}/_db/{db}'.format( - protocol=self._protocol, - host=self._host, - port=self._port, - db=self._database - ) - self._username = username - self._password = password - self._http = http_client or DefaultHTTPClient() - self._enable_logging = enable_logging - self._type = 'standard' - self._logger = logger or logging.getLogger('arango') - - def __repr__(self): - return ''.format(self._database) - - @property - def protocol(self): - """Return the internet transfer protocol. - - :returns: the internet transfer protocol - :rtype: str | unicode - """ - return self._protocol - - @property - def host(self): - """Return the ArangoDB host. - - :returns: the ArangoDB host - :rtype: str | unicode - """ - return self._host - - @property - def port(self): - """Return the ArangoDB port. - - :returns: the ArangoDB port - :rtype: int - """ - return self._port - - @property - def username(self): - """Return the ArangoDB username. - - :returns: the ArangoDB username - :rtype: str | unicode - """ - return self._username - - @property - def password(self): - """Return the ArangoDB user password. - - :returns: the ArangoDB user password - :rtype: str | unicode - """ - return self._password - - @property - def database(self): - """Return the name of the connected database. - - :returns: the name of the connected database - :rtype: str | unicode - """ - return self._database - - @property - def http_client(self): - """Return the HTTP client in use. - - :returns: the HTTP client in use - :rtype: arango.http_clients.base.BaseHTTPClient - """ - return self._http - - @property - def logging_enabled(self): - """Return ``True`` if logging is enabled, ``False`` otherwise. - - :returns: whether logging is enabled or not - :rtype: bool - """ - return self._enable_logging - - @property - def type(self): - """Return the connection type. - - :return: the connection type - :rtype: str | unicode - """ - return self._type - - def handle_request(self, request, handler): - # from arango.async import AsyncExecution - # from arango.exceptions import ArangoError - # async = AsyncExecution(self, return_result=True) - # response = async.handle_request(request, handler) - # while response.status() != 'done': - # pass - # result = response.result() - # if isinstance(result, ArangoError): - # raise result - # return result - - # from arango.batch import BatchExecution - # from arango.exceptions import ArangoError - # - # batch = BatchExecution(self, return_result=True) - # response = batch.handle_request(request, handler) - # batch.commit() - # result = response.result() - # if isinstance(result, ArangoError): - # raise result - # return result - return handler(getattr(self, request.method)(**request.kwargs)) - - def head(self, endpoint, params=None, headers=None, **_): - """Execute a **HEAD** API method. - - :param endpoint: the API endpoint - :type endpoint: str | unicode - :param params: the request parameters - :type params: dict - :param headers: the request headers - :type headers: dict - :returns: the ArangoDB http response - :rtype: arango.response.Response - """ - url = self._url_prefix + endpoint - res = self._http.head( - url=url, - params=params, - headers=headers, - auth=(self._username, self._password) - ) - if self._enable_logging: - self._logger.debug('HEAD {} {}'.format(url, res.status_code)) - return res - - def get(self, endpoint, params=None, headers=None, **_): - """Execute a **GET** API method. - - :param endpoint: the API endpoint - :type endpoint: str | unicode - :param params: the request parameters - :type params: dict - :param headers: the request headers - :type headers: dict - :returns: the ArangoDB http response - :rtype: arango.response.Response - """ - url = self._url_prefix + endpoint - res = self._http.get( - url=url, - params=params, - headers=headers, - auth=(self._username, self._password) - ) - if self._enable_logging: - self._logger.debug('GET {} {}'.format(url, res.status_code)) - return res - - def put(self, endpoint, data=None, params=None, headers=None, **_): - """Execute a **PUT** API method. - - :param endpoint: the API endpoint - :type endpoint: str | unicode - :param data: the request payload - :type data: str | unicode | dict - :param params: the request parameters - :type params: dict - :param headers: the request headers - :type headers: dict - :returns: the ArangoDB http response - :rtype: arango.response.Response - """ - url = self._url_prefix + endpoint - res = self._http.put( - url=url, - data=sanitize(data), - params=params, - headers=headers, - auth=(self._username, self._password) - ) - if self._enable_logging: - self._logger.debug('PUT {} {}'.format(url, res.status_code)) - return res - - def post(self, endpoint, data=None, params=None, headers=None, **_): - """Execute a **POST** API method. - - :param endpoint: the API endpoint - :type endpoint: str | unicode - :param data: the request payload - :type data: str | unicode | dict - :param params: the request parameters - :type params: dict - :param headers: the request headers - :type headers: dict - :returns: the ArangoDB http response - :rtype: arango.response.Response - """ - url = self._url_prefix + endpoint - res = self._http.post( - url=url, - data=sanitize(data), - params=params, - headers=headers, - auth=(self._username, self._password) - ) - if self._enable_logging: - self._logger.debug('POST {} {}'.format(url, res.status_code)) - return res - - def patch(self, endpoint, data=None, params=None, headers=None, **_): - """Execute a **PATCH** API method. - - :param endpoint: the API endpoint - :type endpoint: str | unicode - :param data: the request payload - :type data: str | unicode | dict - :param params: the request parameters - :type params: dict - :param headers: the request headers - :type headers: dict - :returns: the ArangoDB http response - :rtype: arango.response.Response - """ - url = self._url_prefix + endpoint - res = self._http.patch( - url=url, - data=sanitize(data), - params=params, - headers=headers, - auth=(self._username, self._password) - ) - if self._enable_logging: - self._logger.debug('PATCH {} {}'.format(url, res.status_code)) - return res - - def delete(self, endpoint, data=None, params=None, headers=None, **_): - """Execute a **DELETE** API method. - - :param endpoint: the API endpoint - :type endpoint: str | unicode - :param data: the request payload - :type data: str | unicode | dict - :param params: the request parameters - :type params: dict - :param headers: the request headers - :type headers: dict - :returns: the ArangoDB http response - :rtype: arango.response.Response - """ - url = self._url_prefix + endpoint - res = self._http.delete( - url=url, - data=sanitize(data), - params=params, - headers=headers, - auth=(self._username, self._password) - ) - if self._enable_logging: - self._logger.debug('DELETE {} {}'.format(url, res.status_code)) - return res diff --git a/arango/connections/__init__.py b/arango/connections/__init__.py new file mode 100644 index 00000000..c41b12a3 --- /dev/null +++ b/arango/connections/__init__.py @@ -0,0 +1,5 @@ +from .base import BaseConnection +from arango.connections.executions.async import AsyncExecution +from arango.connections.executions.batch import BatchExecution +from arango.connections.executions.cluster import ClusterTest +from arango.connections.executions.transaction import TransactionExecution diff --git a/arango/connections/base.py b/arango/connections/base.py new file mode 100644 index 00000000..99ee3b1c --- /dev/null +++ b/arango/connections/base.py @@ -0,0 +1,259 @@ +from __future__ import absolute_import, unicode_literals + +import logging + +from arango.jobs import BaseJob, OldJob +from arango.http_clients import DefaultHTTPClient +from arango.utils import sanitize, fix_params +from arango.api.wrappers import AQL, Graph +from arango.api.collections import Collection + + +class BaseConnection(object): + """ArangoDB database connection. + + :param protocol: the internet transfer protocol (default: ``"http"``) + :type protocol: str | unicode + :param host: ArangoDB host (default: ``"localhost"``) + :type host: str | unicode + :param port: ArangoDB port (default: ``8529``) + :type port: int | str | unicode + :param database: the name of the target database (default: ``"_system"``) + :type database: str | unicode + :param username: ArangoDB username (default: ``"root"``) + :type username: str | unicode + :param password: ArangoDB password (default: ``""``) + :type password: str | unicode + :param http_client: the HTTP client + :type http_client: arango.clients.base.BaseHTTPClient + :param enable_logging: log all API requests with a logger named "arango" + :type enable_logging: bool + """ + + def __init__(self, + protocol='http', + host='localhost', + port=8529, + database="_system", + username='root', + password='', + http_client=None, + enable_logging=True, + logger=None, + old_behavior=True): + + if http_client is None: + http_client = DefaultHTTPClient() + + if logger is None: + logger = logging.getLogger('arango') + + self._protocol = protocol.strip('/') + self._host = host.strip('/') + self._port = port + self._database = database + self._url_prefix = '{protocol}://{host}:{port}/_db/{db}'.format( + protocol=self._protocol, + host=self._host, + port=self._port, + db=self._database + ) + self._username = username + self._password = password + self._http = http_client + self._enable_logging = enable_logging + self._type = 'standard' + self._logger = logger + self._old_behavior = old_behavior + + if old_behavior: + self._default_job = OldJob + else: + self._default_job = BaseJob + + self._aql = AQL(self) + self._parent = None + + def __repr__(self): + return ''.format(self._database) + + @property + def underlying(self): + if self._parent is None: + return self + else: + return self._parent.underlying + + @property + def protocol(self): + """Return the internet transfer protocol. + + :returns: the internet transfer protocol + :rtype: str | unicode + """ + return self._protocol + + @property + def host(self): + """Return the ArangoDB host. + + :returns: the ArangoDB host + :rtype: str | unicode + """ + return self._host + + @property + def port(self): + """Return the ArangoDB port. + + :returns: the ArangoDB port + :rtype: int + """ + return self._port + + @property + def username(self): + """Return the ArangoDB username. + + :returns: the ArangoDB username + :rtype: str | unicode + """ + return self._username + + @property + def password(self): + """Return the ArangoDB user password. + + :returns: the ArangoDB user password + :rtype: str | unicode + """ + return self._password + + @property + def database(self): + """Return the name of the connected database. + + :returns: the name of the connected database + :rtype: str | unicode + """ + return self._database + + @property + def http_client(self): + """Return the HTTP client in use. + + :returns: the HTTP client in use + :rtype: arango.http_clients.base.BaseHTTPClient + """ + return self._http + + @property + def logging_enabled(self): + """Return ``True`` if logging is enabled, ``False`` otherwise. + + :returns: whether logging is enabled or not + :rtype: bool + """ + return self._enable_logging + + @property + def type(self): + """Return the connection type. + + :return: the connection type + :rtype: str | unicode + """ + return self._type + + @property + def logger(self): + return self._logger + + @property + def old_behavior(self): + return self._old_behavior + + def handle_request(self, request, handler, job_class=None, + **kwargs): + """Handle a given request + + :param request: The request to make + :type request: arango.request.Request + :param handler: The response handler to use to process the response + :type handler: callable + :param job_class: the class of the :class:arango.jobs.BaseJob to + output or None to use the default job for this connection + :type job_class: class | None + :param kwargs: keyword arguments to be passed to the + :class:arango.jobs.BaseJob constructor + :return: the job output + :rtype: arango.jobs.BaseJob + """ + if job_class is None: + job_class = self._default_job + + endpoint = request.url + request.url = self._url_prefix + request.url + request.data = sanitize(request.data) + request.params = fix_params(request.params) + + used_handler = handler + + if self._enable_logging: + def new_handler(res): + handled = handler(res) + self._logger.debug('{} {} {}'.format( + request.method, + endpoint, + res.status_code)) + + return handled + + used_handler = new_handler + + if request.auth is None: + request.auth = (self._username, self._password) + + response = self._http.make_request(request) + return job_class(used_handler, response, **kwargs) + + @property + def aql(self): + """Return the AQL object tailored for asynchronous execution. + + API requests via the returned query object are placed in a server-side + in-memory task queue and executed asynchronously in a fire-and-forget + style. + + :returns: ArangoDB query object + :rtype: arango.query.AQL + """ + return self._aql + + def collection(self, name): + """Return a collection object tailored for asynchronous execution. + + API requests via the returned collection object are placed in a + server-side in-memory task queue and executed asynchronously in + a fire-and-forget style. + + :param name: the name of the collection + :type name: str | unicode + :returns: the collection object + :rtype: arango.collections.Collection + """ + return Collection(self, name) + + def graph(self, name): + """Return a graph object tailored for asynchronous execution. + + API requests via the returned graph object are placed in a server-side + in-memory task queue and executed asynchronously in a fire-and-forget + style. + + :param name: the name of the graph + :type name: str | unicode + :returns: the graph object + :rtype: arango.graph.Graph + """ + return Graph(self, name) diff --git a/arango/connections/executions/__init__.py b/arango/connections/executions/__init__.py new file mode 100644 index 00000000..05d9fde0 --- /dev/null +++ b/arango/connections/executions/__init__.py @@ -0,0 +1,4 @@ +from .async import AsyncExecution +from .batch import BatchExecution +from .cluster import ClusterTest +from .transaction import TransactionExecution diff --git a/arango/connections/executions/async.py b/arango/connections/executions/async.py new file mode 100644 index 00000000..a438dd86 --- /dev/null +++ b/arango/connections/executions/async.py @@ -0,0 +1,66 @@ +from __future__ import absolute_import, unicode_literals + +from arango.jobs import AsyncJob +from arango.connections import BaseConnection + + +class AsyncExecution(BaseConnection): + """ArangoDB asynchronous execution. + + API requests via this class are placed in a server-side in-memory task + queue and executed asynchronously in a fire-and-forget style. + + :param connection: ArangoDB database connection + :type connection: arango.connection.Connection + :param return_result: if ``True``, an :class:`arango.async.AsyncJob` + instance (which holds the result of the request) is returned each + time an API request is queued, otherwise ``None`` is returned + :type return_result: bool + + .. warning:: + Asynchronous execution is currently an experimental feature and is not + thread-safe. + """ + + def __init__(self, connection, return_result=True): + super(AsyncExecution, self).__init__( + protocol=connection.protocol, + host=connection.host, + port=connection.port, + username=connection.username, + password=connection.password, + http_client=connection.http_client, + database=connection.database, + enable_logging=connection.logging_enabled, + logger=connection.logger, + old_behavior=connection.old_behavior + ) + self._return_result = return_result + self._type = 'async' + self._parent = connection + + def __repr__(self): + return '' + + def handle_request(self, request, handler, **kwargs): + """Handle the incoming request and response handler. + + :param request: the API request to be placed in the server-side queue + :type request: arango.request.Request + :param handler: the response handler + :type handler: callable + :returns: the async job or None + :rtype: arango.async.AsyncJob + :raises arango.exceptions.AsyncExecuteError: if the async request + cannot be executed + """ + if self._return_result: + request.headers['x-arango-async'] = 'store' + else: + request.headers['x-arango-async'] = 'true' + + kwargs["job_class"] = AsyncJob + kwargs["connection"] = self._parent + kwargs["return_result"] = self._return_result + + return BaseConnection.handle_request(self, request, handler, **kwargs) diff --git a/arango/batch.py b/arango/connections/executions/batch.py similarity index 50% rename from arango/batch.py rename to arango/connections/executions/batch.py index 27d250a8..dd3c13c2 100644 --- a/arango/batch.py +++ b/arango/connections/executions/batch.py @@ -2,17 +2,16 @@ from uuid import uuid4 -from arango.lock import Lock -from arango.collections.standard import Collection -from arango.connection import Connection +from arango.request import Request +from arango.jobs import BatchJob, BaseJob +from arango.utils.lock import Lock +from arango.connections import BaseConnection from arango.utils import HTTP_OK -from arango.exceptions import BatchExecuteError, ArangoError -from arango.graph import Graph -from arango.response import Response -from arango.aql import AQL +from arango.exceptions import BatchExecuteError +from arango.responses import BaseResponse -class BatchExecution(Connection): +class BatchExecution(BaseConnection): """ArangoDB batch request. API requests via this class are queued in memory and executed as a whole @@ -49,20 +48,20 @@ def __init__(self, connection, return_result=True, commit_on_error=False, database=connection.database, enable_logging=connection.logging_enabled ) - self._id = uuid4() + self._id = uuid4().hex self._return_result = return_result self._commit_on_error = commit_on_error self._requests = [] # The queue for requests - self._handlers = [] # The queue for response handlers self._batch_jobs = [] # For tracking batch jobs # For ensuring additions to all queues in the same order self._lock = Lock() self._batch_submit_timeout = submit_timeout - self._aql = AQL(self) self._type = 'batch' + self._parent = connection + def __repr__(self): return ''.format(self._id) @@ -82,7 +81,7 @@ def id(self): """ return self._id - def handle_request(self, request, handler): + def handle_request(self, request, handler, **kwargs): """Handle the incoming request and response handler. :param request: the API request queued as part of the current batch @@ -95,17 +94,20 @@ def handle_request(self, request, handler): :rtype: :class:`arango.batch.BatchJob` | None """ - to_return = None + batch_job = None with self._lock: self._requests.append(request) - self._handlers.append(handler) if self._return_result: - to_return = BatchJob() - self._batch_jobs.append(to_return) + batch_job = BatchJob(handler) + self._batch_jobs.append(batch_job) + + return batch_job - return to_return + @staticmethod + def response_mapper(response): + return response def commit(self): """Execute the queued API requests in a single HTTP call. @@ -122,9 +124,9 @@ def commit(self): cannot be executed """ - res = self._lock.acquire(timeout=self._batch_submit_timeout) + lock_res = self._lock.acquire(timeout=self._batch_submit_timeout) - if not res: + if not lock_res: raise BatchExecuteError("Unable to reaccquire lock within time " "period. Some thread must be holding it.") @@ -133,57 +135,66 @@ def commit(self): return raw_data_list = [] - for content_id, request in enumerate(self._requests, start=1): + for content_id, one_request in enumerate(self._requests, start=1): raw_data_list.append('--XXXsubpartXXX\r\n') raw_data_list.append( 'Content-Type: application/x-arango-batchpart\r\n') raw_data_list.append( 'Content-Id: {}\r\n\r\n'.format(content_id)) - raw_data_list.append('{}\r\n'.format(request.stringify())) + raw_data_list.append('{}\r\n'.format(one_request.stringify())) raw_data_list.append('--XXXsubpartXXX--\r\n\r\n') raw_data = ''.join(raw_data_list) - res = self.post( - endpoint='/_api/batch', + batch_request = Request( + method="post", + url='/_api/batch', headers={ 'Content-Type': ( 'multipart/form-data; boundary=XXXsubpartXXX' ) }, - data=raw_data, + data=raw_data ) - if res.status_code not in HTTP_OK: - raise BatchExecuteError(res) - if not self._return_result: - return - for index, raw_response in enumerate( - res.raw_body.split('--XXXsubpartXXX')[1:-1] - ): - request = self._requests[index] - handler = self._handlers[index] - job = self._batch_jobs[index] - res_parts = raw_response.strip().split('\r\n') - raw_status, raw_body = res_parts[3], res_parts[-1] - _, status_code, status_text = raw_status.split(' ', 2) - try: - result = handler(Response( - method=request.method, - url=self._url_prefix + request.endpoint, - headers=request.headers, - http_code=int(status_code), - http_text=status_text, - body=raw_body - )) - except ArangoError as err: - job.update(status='error', result=err) - else: - job.update(status='done', result=result) + def handler(res): + if res.status_code not in HTTP_OK: + raise BatchExecuteError(res) + if not self._return_result: + return + + for index, raw_response in enumerate( + res.raw_body.split('--XXXsubpartXXX')[1:-1] + ): + request = self._requests[index] + job = self._batch_jobs[index] + res_parts = raw_response.strip().split('\r\n') + raw_status, raw_body = res_parts[3], res_parts[-1] + _, status_code, status_text = raw_status.split(' ', 2) + + response_dict = { + "method": request.method, + "url": self._url_prefix + request.url, + "headers": request.headers, + "status_code": int(status_code), + "status_text": status_text, + "body": raw_body + } + + response = BaseResponse(response_dict, + self.response_mapper) + + if int(status_code) in HTTP_OK: + job.update("done", response) + else: + job.update("error", response) + + BaseConnection.handle_request(self, batch_request, handler, + job_class=BaseJob)\ + .result(raise_errors=True) return self._batch_jobs finally: self._requests = [] - self._handlers = [] self._batch_jobs = [] self._lock.release() @@ -198,105 +209,5 @@ def clear(self): """ count = len(self._requests) self._requests = [] - self._handlers = [] self._batch_jobs = [] return count - - @property - def aql(self): - """Return the AQL object tailored for batch execution. - - API requests via the returned object are placed in an in-memory queue - and committed as a whole in a single HTTP call to the ArangoDB server. - - :returns: ArangoDB query object - :rtype: arango.query.AQL - """ - return self._aql - - def collection(self, name): - """Return the collection object tailored for batch execution. - - API requests via the returned object are placed in an in-memory queue - and committed as a whole in a single HTTP call to the ArangoDB server. - - :param name: the name of the collection - :type name: str | unicode - :returns: the collection object - :rtype: arango.collections.Collection - """ - return Collection(self, name) - - def graph(self, name): - """Return the graph object tailored for batch execution. - - API requests via the returned object are placed in an in-memory queue - and committed as a whole in a single HTTP call to the ArangoDB server. - - :param name: the name of the graph - :type name: str | unicode - :returns: the graph object - :rtype: arango.graph.Graph - """ - return Graph(self, name) - - -class BatchJob(object): - """ArangoDB batch job which holds the result of an API request. - - A batch job tracks the status of a queued API request and its result. - """ - - def __init__(self): - self._id = uuid4() - self._status = 'pending' - self._result = None - self._lock = Lock() - - def __repr__(self): - return ''.format(self._id) - - @property - def id(self): - """Return the UUID of the batch job. - - :return: the UUID of the batch job - :rtype: str | unicode - """ - return self._id - - def update(self, status, result=None): - """Update the status and the result of the batch job. - - This method designed to be used internally only. - - :param status: the status of the job - :type status: int - :param result: the result of the job - :type result: object - """ - - with self._lock: - self._status = status - self._result = result - - def status(self): - """Return the status of the batch job. - - :returns: the batch job status, which can be ``"pending"`` (the job is - still waiting to be committed), ``"done"`` (the job completed) or - ``"error"`` (the job raised an exception) - :rtype: str | unicode - """ - with self._lock: - return self._status - - def result(self): - """Return the result of the job or raise its error. - - :returns: the result of the batch job if the job is successful - :rtype: object - :raises ArangoError: if the batch job failed - """ - with self._lock: - return self._result diff --git a/arango/cluster.py b/arango/connections/executions/cluster.py similarity index 55% rename from arango/cluster.py rename to arango/connections/executions/cluster.py index e5d6045c..1ee2f6c9 100644 --- a/arango/cluster.py +++ b/arango/connections/executions/cluster.py @@ -1,14 +1,9 @@ from __future__ import absolute_import, unicode_literals -from arango.aql import AQL -from arango.collections.standard import Collection -from arango.connection import Connection -from arango.exceptions import ClusterTestError -from arango.graph import Graph -from arango.utils import HTTP_OK +from arango.connections.base import BaseConnection -class ClusterTest(Connection): +class ClusterTest(BaseConnection): """ArangoDB cluster round-trip test for sharding. :param connection: ArangoDB database connection @@ -42,17 +37,26 @@ def __init__(self, database=connection.database, enable_logging=connection.logging_enabled ) + + self._url_prefix = \ + "{protocol}://{host}:{port}/_admin/cluster-test/_db/{db}".format( + protocol=self._protocol, + host=self._host, + port=self._port, + db=self._database + ) + self._shard_id = shard_id self._trans_id = transaction_id self._timeout = timeout self._sync = sync - self._aql = AQL(self) self._type = 'cluster' + self._parent = connection def __repr__(self): return '' - def handle_request(self, request, handler): + def handle_request(self, request, handler, **kwargs): """Handle the incoming request and response handler. :param request: the API request to be placed in the server-side queue @@ -72,49 +76,4 @@ def handle_request(self, request, handler): if self._sync is True: request.headers['X-Synchronous-Mode'] = 'true' - request.endpoint = '/_admin/cluster-test' + request.endpoint + '11' - res = getattr(self, request.method)(**request.kwargs) - if res.status_code not in HTTP_OK: - raise ClusterTestError(res) - return res.body # pragma: no cover - - @property - def aql(self): - """Return the AQL object tailored for asynchronous execution. - - API requests via the returned query object are placed in a server-side - in-memory task queue and executed asynchronously in a fire-and-forget - style. - - :returns: ArangoDB query object - :rtype: arango.query.AQL - """ - return self._aql - - def collection(self, name): - """Return a collection object tailored for asynchronous execution. - - API requests via the returned collection object are placed in a - server-side in-memory task queue and executed asynchronously in - a fire-and-forget style. - - :param name: the name of the collection - :type name: str | unicode - :returns: the collection object - :rtype: arango.collections.Collection - """ - return Collection(self, name) - - def graph(self, name): - """Return a graph object tailored for asynchronous execution. - - API requests via the returned graph object are placed in a server-side - in-memory task queue and executed asynchronously in a fire-and-forget - style. - - :param name: the name of the graph - :type name: str | unicode - :returns: the graph object - :rtype: arango.graph.Graph - """ - return Graph(self, name) + return BaseConnection.handle_request(self, request, handler) diff --git a/arango/transaction.py b/arango/connections/executions/transaction.py similarity index 68% rename from arango/transaction.py rename to arango/connections/executions/transaction.py index 6f3332d4..75c426be 100644 --- a/arango/transaction.py +++ b/arango/connections/executions/transaction.py @@ -1,14 +1,15 @@ from __future__ import absolute_import, unicode_literals from uuid import uuid4 +from collections import deque -from arango.collections.standard import Collection -from arango.connection import Connection +from arango.connections import BaseConnection +from arango.request import Request from arango.utils import HTTP_OK from arango.exceptions import TransactionError -class Transaction(Connection): +class TransactionExecution(BaseConnection): """ArangoDB transaction object. API requests made in a transaction are queued in memory and executed as a @@ -41,7 +42,7 @@ def __init__(self, sync=None, timeout=None, commit_on_error=False): - super(Transaction, self).__init__( + super(TransactionExecution, self).__init__( protocol=connection.protocol, host=connection.host, port=connection.port, @@ -51,8 +52,8 @@ def __init__(self, database=connection.database, enable_logging=connection.logging_enabled ) - self._id = uuid4() - self._actions = ['db = require("internal").db'] + self._id = uuid4().hex + self._actions = [] self._collections = {} if read: self._collections['read'] = read @@ -63,6 +64,8 @@ def __init__(self, self._commit_on_error = commit_on_error self._type = 'transaction' + self._parent = connection + def __repr__(self): return ''.format(self._id) @@ -82,7 +85,7 @@ def id(self): """ return self._id - def handle_request(self, request, handler): + def handle_request(self, request, handler, **kwargs): """Handle the incoming request and response handler. :param request: the API request queued as part of the transaction, and @@ -114,58 +117,88 @@ def execute(self, command, params=None, sync=None, timeout=None): :raises arango.exceptions.TransactionError: if the transaction cannot be executed """ + + def handler(res): + if res.status_code not in HTTP_OK: + raise TransactionError(res) + return res.body.get('result') + data = {'collections': self._collections, 'action': command} - timeout = self._timeout if timeout is None else timeout - sync = self._sync if sync is None else sync + + if timeout is None: + timeout = self._timeout if timeout is not None: data['lockTimeout'] = timeout + + if sync is None: + sync = self._sync + if sync is not None: data['waitForSync'] = sync + if params is not None: data['params'] = params - res = self.post(endpoint='/_api/transaction', data=data) - if res.status_code not in HTTP_OK: - raise TransactionError(res) - return res.body.get('result') + request = Request( + method='post', + url='/_api/transaction', + data=data + ) + + return self.underlying.handle_request(request, handler) def commit(self): """Execute the queued API requests in a single atomic step. :return: the result of the transaction - :rtype: dict + :rtype: :class:arango.jobs.Job :raises arango.exceptions.TransactionError: if the transaction cannot be executed """ - try: - action = ';'.join(self._actions) - res = self.post( - endpoint='/_api/transaction', - data={ - 'collections': self._collections, - 'action': 'function () {{ {} }}'.format(action) - }, - params={ - 'lockTimeout': self._timeout, - 'waitForSync': self._sync, - } - ) + + def handler(res): if res.status_code not in HTTP_OK: raise TransactionError(res) return res.body.get('result') - finally: - self._actions = ['db = require("internal").db'] - def collection(self, name): - """Return the collection object tailored for transactions. + action_labels = ["a" + uuid4().hex for _ in self._actions] + + action_strings = deque() + action_strings.append('db = require("internal").db;\n') + + for i in range(len(self._actions)): + action_strings.append("var ") + action_strings.append(action_labels[i]) + action_strings.append(" = ") + action_strings.append(self._actions[i]) + action_strings.append(";\n") + + action_strings.append("return [") + for label in action_labels: + action_strings.append(label) + action_strings.append(", ") + + if len(action_labels) > 0: + action_strings.pop() + + action_strings.append("];\n") + + action = "".join(action_strings) + + request = Request( + method='post', + url='/_api/transaction', + data={ + 'collections': self._collections, + 'action': 'function () {{ {} }}'.format(action) + }, + params={ + 'lockTimeout': self._timeout, + 'waitForSync': self._sync, + } + ) - API requests via the returned object are placed in an in-memory queue - and committed as a whole in a single HTTP call to the ArangoDB server. + self._actions = ['db = require("internal").db'] - :param name: the name of the collection - :type name: str | unicode - :returns: the collection object - :rtype: arango.collections.Collection - """ - return Collection(self, name) + return self.underlying.handle_request(request, handler) diff --git a/arango/exceptions.py b/arango/exceptions.py deleted file mode 100644 index b54fd5e5..00000000 --- a/arango/exceptions.py +++ /dev/null @@ -1,535 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -from six import string_types as string - -from arango.response import Response - - -class ArangoError(Exception): - """Base class for all ArangoDB exceptions. - - :param data: the response object or string - :type data: arango.response.Response | str | unicode - """ - - def __init__(self, data, message=None): - if isinstance(data, Response): - # Get the ArangoDB error message if provided - if message is not None: - error_message = message - elif data.error_message is not None: - error_message = data.error_message - elif data.status_text is not None: - error_message = data.status_text - else: # pragma: no cover - error_message = "request failed" - - # Get the ArangoDB error number if provided - self.error_code = data.error_code - - # Build the error message for the exception - if self.error_code is None: - error_message = '[HTTP {}] {}'.format( - data.status_code, - error_message - ) - else: - error_message = '[HTTP {}][ERR {}] {}'.format( - data.status_code, - self.error_code, - error_message - ) - # Generate the error message for the exception - super(ArangoError, self).__init__(error_message) - self.message = error_message - self.http_method = data.method - self.url = data.url - self.http_code = data.status_code - self.http_headers = data.headers - elif isinstance(data, string): - super(ArangoError, self).__init__(data) - self.message = data - self.error_code = None - self.url = None - self.http_method = None - self.http_code = None - self.http_headers = None - - -##################### -# Server Exceptions # -##################### - - -class ServerConnectionError(ArangoError): - """Failed to connect to the ArangoDB instance.""" - - -class ServerEndpointsError(ArangoError): - """Failed to retrieve the ArangoDB server endpoints.""" - - -class ServerVersionError(ArangoError): - """Failed to retrieve the ArangoDB server version.""" - - -class ServerDetailsError(ArangoError): - """Failed to retrieve the ArangoDB server details.""" - - -class ServerTimeError(ArangoError): - """Failed to return the current ArangoDB system time.""" - - -class ServerEchoError(ArangoError): - """Failed to return the last request.""" - - -class ServerSleepError(ArangoError): - """Failed to suspend the ArangoDB server.""" - - -class ServerShutdownError(ArangoError): - """Failed to initiate a clean shutdown sequence.""" - - -class ServerRunTestsError(ArangoError): - """Failed to execute the specified tests on the server.""" - - -class ServerExecuteError(ArangoError): - """Failed to execute a the given Javascript program on the server.""" - - -class ServerRequiredDBVersionError(ArangoError): - """Failed to retrieve the required database version.""" - - -class ServerReadLogError(ArangoError): - """Failed to retrieve the global log.""" - - -class ServerLogLevelError(ArangoError): - """Failed to return the log level.""" - - -class ServerLogLevelSetError(ArangoError): - """Failed to set the log level.""" - - -class ServerReloadRoutingError(ArangoError): - """Failed to reload the routing information.""" - - -class ServerStatisticsError(ArangoError): - """Failed to retrieve the server statistics.""" - - -class ServerRoleError(ArangoError): - """Failed to retrieve the role of the server in a cluster.""" - - -############################## -# Write-Ahead Log Exceptions # -############################## - - -class WALPropertiesError(ArangoError): - """Failed to retrieve the write-ahead log.""" - - -class WALConfigureError(ArangoError): - """Failed to configure the write-ahead log.""" - - -class WALTransactionListError(ArangoError): - """Failed to retrieve the list of running transactions.""" - - -class WALFlushError(ArangoError): - """Failed to flush the write-ahead log.""" - - -################### -# Task Exceptions # -################### - - -class TaskListError(ArangoError): - """Failed to list the active server tasks.""" - - -class TaskGetError(ArangoError): - """Failed to retrieve the active server task.""" - - -class TaskCreateError(ArangoError): - """Failed to create a server task.""" - - -class TaskDeleteError(ArangoError): - """Failed to delete a server task.""" - - -####################### -# Database Exceptions # -####################### - - -class DatabaseListError(ArangoError): - """Failed to retrieve the list of databases.""" - - -class DatabasePropertiesError(ArangoError): - """Failed to retrieve the database options.""" - - -class DatabaseCreateError(ArangoError): - """Failed to create the database.""" - - -class DatabaseDeleteError(ArangoError): - """Failed to delete the database.""" - - -################### -# User Exceptions # -################### - - -class UserListError(ArangoError): - """Failed to retrieve the users.""" - - -class UserGetError(ArangoError): - """Failed to retrieve the user.""" - - -class UserCreateError(ArangoError): - """Failed to create the user.""" - - -class UserUpdateError(ArangoError): - """Failed to update the user.""" - - -class UserReplaceError(ArangoError): - """Failed to replace the user.""" - - -class UserDeleteError(ArangoError): - """Failed to delete the user.""" - - -class UserAccessError(ArangoError): - """Failed to retrieve the names of databases user can access.""" - - -class UserGrantAccessError(ArangoError): - """Failed to grant user access to a database.""" - - -class UserRevokeAccessError(ArangoError): - """Failed to revoke user access to a database.""" - - -######################### -# Collection Exceptions # -######################### - - -class CollectionListError(ArangoError): - """Failed to retrieve the list of collections.""" - - -class CollectionPropertiesError(ArangoError): - """Failed to retrieve the collection properties.""" - - -class CollectionConfigureError(ArangoError): - """Failed to configure the collection properties.""" - - -class CollectionStatisticsError(ArangoError): - """Failed to retrieve the collection statistics.""" - - -class CollectionRevisionError(ArangoError): - """Failed to retrieve the collection revision.""" - - -class CollectionChecksumError(ArangoError): - """Failed to retrieve the collection checksum.""" - - -class CollectionCreateError(ArangoError): - """Failed to create the collection.""" - - -class CollectionDeleteError(ArangoError): - """Failed to delete the collection""" - - -class CollectionRenameError(ArangoError): - """Failed to rename the collection.""" - - -class CollectionTruncateError(ArangoError): - """Failed to truncate the collection.""" - - -class CollectionLoadError(ArangoError): - """Failed to load the collection into memory.""" - - -class CollectionUnloadError(ArangoError): - """Failed to unload the collection from memory.""" - - -class CollectionRotateJournalError(ArangoError): - """Failed to rotate the journal of the collection.""" - - -class CollectionBadStatusError(ArangoError): - """Unknown status was returned from the collection.""" - - -####################### -# Document Exceptions # -####################### - - -class DocumentCountError(ArangoError): - """Failed to retrieve the count of the documents in the collections.""" - - -class DocumentInError(ArangoError): - """Failed to check whether a collection contains a document.""" - - -class DocumentGetError(ArangoError): - """Failed to retrieve the document.""" - - -class DocumentInsertError(ArangoError): - """Failed to insert the document.""" - - -class DocumentReplaceError(ArangoError): - """Failed to replace the document.""" - - -class DocumentUpdateError(ArangoError): - """Failed to update the document.""" - - -class DocumentDeleteError(ArangoError): - """Failed to delete the document.""" - - -class DocumentRevisionError(ArangoError): - """The expected and actual document revisions do not match.""" - - -#################### -# Index Exceptions # -#################### - - -class IndexListError(ArangoError): - """Failed to retrieve the list of indexes in the collection.""" - - -class IndexCreateError(ArangoError): - """Failed to create the index in the collection.""" - - -class IndexDeleteError(ArangoError): - """Failed to delete the index from the collection.""" - - -################## -# AQL Exceptions # -################## - - -class AQLQueryExplainError(ArangoError): - """Failed to explain the AQL query.""" - - -class AQLQueryValidateError(ArangoError): - """Failed to validate the AQL query.""" - - -class AQLQueryExecuteError(ArangoError): - """Failed to execute the AQL query.""" - - -class AQLCacheClearError(ArangoError): - """Failed to clear the AQL query cache.""" - - -class AQLCachePropertiesError(ArangoError): - """Failed to retrieve the AQL query cache properties.""" - - -class AQLCacheConfigureError(ArangoError): - """Failed to configure the AQL query cache properties.""" - - -class AQLFunctionListError(ArangoError): - """Failed to retrieve the list of AQL user functions.""" - - -class AQLFunctionCreateError(ArangoError): - """Failed to create the AQL user function.""" - - -class AQLFunctionDeleteError(ArangoError): - """Failed to delete the AQL user function.""" - - -##################### -# Cursor Exceptions # -##################### - - -class CursorNextError(ArangoError): - """Failed to retrieve the next cursor result.""" - - -class CursorCloseError(ArangoError): - """Failed to delete the cursor from the server.""" - - -########################## -# Transaction Exceptions # -########################## - - -class TransactionError(ArangoError): - """Failed to execute a transaction.""" - - -#################### -# Batch Exceptions # -#################### - - -class BatchExecuteError(ArangoError): - """Failed to execute the batch request.""" - - -#################### -# Async Exceptions # -#################### - - -class AsyncExecuteError(ArangoError): - """Failed to execute the asynchronous request.""" - - -class AsyncJobListError(ArangoError): - """Failed to list the IDs of the asynchronous jobs.""" - - -class AsyncJobCancelError(ArangoError): - """Failed to cancel the asynchronous job.""" - - -class AsyncJobStatusError(ArangoError): - """Failed to retrieve the asynchronous job result from the server.""" - - -class AsyncJobResultError(ArangoError): - """Failed to pop the asynchronous job result from the server.""" - - -class AsyncJobClearError(ArangoError): - """Failed to delete the asynchronous job result from the server.""" - - -##################### -# Pregel Exceptions # -##################### - -class PregelJobCreateError(ArangoError): - """Failed to start/create a Pregel job.""" - - -class PregelJobGetError(ArangoError): - """Failed to retrieve a Pregel job.""" - - -class PregelJobDeleteError(ArangoError): - """Failed to cancel/delete a Pregel job.""" - - -########################### -# Cluster Test Exceptions # -########################### - - -class ClusterTestError(ArangoError): - """Failed to execute the cluster round-trip for sharding.""" - - -#################### -# Graph Exceptions # -#################### - - -class GraphListError(ArangoError): - """Failed to retrieve the list of graphs.""" - - -class GraphGetError(ArangoError): - """Failed to retrieve the graph.""" - - -class GraphCreateError(ArangoError): - """Failed to create the graph.""" - - -class GraphDeleteError(ArangoError): - """Failed to delete the graph.""" - - -class GraphPropertiesError(ArangoError): - """Failed to retrieve the graph properties.""" - - -class GraphTraverseError(ArangoError): - """Failed to execute the graph traversal.""" - - -class OrphanCollectionListError(ArangoError): - """Failed to retrieve the list of orphaned vertex collections.""" - - -class VertexCollectionListError(ArangoError): - """Failed to retrieve the list of vertex collections.""" - - -class VertexCollectionCreateError(ArangoError): - """Failed to create the vertex collection.""" - - -class VertexCollectionDeleteError(ArangoError): - """Failed to delete the vertex collection.""" - - -class EdgeDefinitionListError(ArangoError): - """Failed to retrieve the list of edge definitions.""" - - -class EdgeDefinitionCreateError(ArangoError): - """Failed to create the edge definition.""" - - -class EdgeDefinitionReplaceError(ArangoError): - """Failed to replace the edge definition.""" - - -class EdgeDefinitionDeleteError(ArangoError): - """Failed to delete the edge definition.""" diff --git a/arango/exceptions/__init__.py b/arango/exceptions/__init__.py new file mode 100644 index 00000000..cc152c1c --- /dev/null +++ b/arango/exceptions/__init__.py @@ -0,0 +1,17 @@ +from .base import ArangoError +from .aql import * +from .async import * +from .batch import * +from .collection import * +from .cursor import * +from .database import * +from .document import * +from .graph import * +from .index import * +from .job import * +from .pregel import * +from .server import * +from .task import * +from .transaction import * +from .user import * +from .wal import * diff --git a/arango/exceptions/aql.py b/arango/exceptions/aql.py new file mode 100644 index 00000000..cdfe021e --- /dev/null +++ b/arango/exceptions/aql.py @@ -0,0 +1,41 @@ +from arango.exceptions import ArangoError + + +class AQLError(ArangoError): + """Base class for errors in AQL queries""" + + +class AQLQueryExplainError(AQLError): + """Failed to explain the AQL query.""" + + +class AQLQueryValidateError(AQLError): + """Failed to validate the AQL query.""" + + +class AQLQueryExecuteError(AQLError): + """Failed to execute the AQL query.""" + + +class AQLCacheClearError(AQLError): + """Failed to clear the AQL query cache.""" + + +class AQLCachePropertiesError(AQLError): + """Failed to retrieve the AQL query cache properties.""" + + +class AQLCacheConfigureError(AQLError): + """Failed to configure the AQL query cache properties.""" + + +class AQLFunctionListError(AQLError): + """Failed to retrieve the list of AQL user functions.""" + + +class AQLFunctionCreateError(AQLError): + """Failed to create the AQL user function.""" + + +class AQLFunctionDeleteError(AQLError): + """Failed to delete the AQL user function.""" diff --git a/arango/exceptions/async.py b/arango/exceptions/async.py new file mode 100644 index 00000000..51cb2753 --- /dev/null +++ b/arango/exceptions/async.py @@ -0,0 +1,29 @@ +from arango.exceptions import ArangoError + + +class AsyncError(ArangoError): + """Base class for errors in Async queries""" + + +class AsyncExecuteError(AsyncError): + """Failed to execute the asynchronous request.""" + + +class AsyncJobListError(AsyncError): + """Failed to list the IDs of the asynchronous jobs.""" + + +class AsyncJobCancelError(AsyncError): + """Failed to cancel the asynchronous job.""" + + +class AsyncJobStatusError(AsyncError): + """Failed to retrieve the asynchronous job result from the server.""" + + +class AsyncJobResultError(AsyncError): + """Failed to pop the asynchronous job result from the server.""" + + +class AsyncJobClearError(AsyncError): + """Failed to delete the asynchronous job result from the server.""" diff --git a/arango/exceptions/base.py b/arango/exceptions/base.py new file mode 100644 index 00000000..4db72305 --- /dev/null +++ b/arango/exceptions/base.py @@ -0,0 +1,56 @@ +from __future__ import absolute_import, unicode_literals + +from six import string_types as string + +from arango.responses import BaseResponse + + +class ArangoError(Exception): + """Base class for all ArangoDB exceptions. + + :param data: the response object or string + :type data: arango.response.Response | str | unicode + """ + + def __init__(self, data, message=None): + if isinstance(data, BaseResponse): + # Get the ArangoDB error message if provided + if message is not None: + error_message = message + elif data.error_message is not None: + error_message = data.error_message + elif data.status_text is not None: + error_message = data.status_text + else: # pragma: no cover + error_message = "request failed" + + # Get the ArangoDB error number if provided + self.error_code = data.error_code + + # Build the error message for the exception + if self.error_code is None: + error_message = '[HTTP {}] {}'.format( + data.status_code, + error_message + ) + else: + error_message = '[HTTP {}][ERR {}] {}'.format( + data.status_code, + self.error_code, + error_message + ) + # Generate the error message for the exception + super(ArangoError, self).__init__(error_message) + self.message = error_message + self.http_method = data.method + self.url = data.url + self.http_code = data.status_code + self.http_headers = data.headers + elif isinstance(data, string): + super(ArangoError, self).__init__(data) + self.message = data + self.error_code = None + self.url = None + self.http_method = None + self.http_code = None + self.http_headers = None diff --git a/arango/exceptions/batch.py b/arango/exceptions/batch.py new file mode 100644 index 00000000..4bd7f4c4 --- /dev/null +++ b/arango/exceptions/batch.py @@ -0,0 +1,5 @@ +from arango.exceptions import ArangoError + + +class BatchExecuteError(ArangoError): + """Failed to execute the batch request.""" diff --git a/arango/exceptions/collection.py b/arango/exceptions/collection.py new file mode 100644 index 00000000..7804fbf3 --- /dev/null +++ b/arango/exceptions/collection.py @@ -0,0 +1,61 @@ +from arango.exceptions import ArangoError + + +class CollectionError(ArangoError): + """Base class for errors in Collection queries""" + + +class CollectionListError(CollectionError): + """Failed to retrieve the list of collections.""" + + +class CollectionPropertiesError(CollectionError): + """Failed to retrieve the collection properties.""" + + +class CollectionConfigureError(CollectionError): + """Failed to configure the collection properties.""" + + +class CollectionStatisticsError(CollectionError): + """Failed to retrieve the collection statistics.""" + + +class CollectionRevisionError(CollectionError): + """Failed to retrieve the collection revision.""" + + +class CollectionChecksumError(CollectionError): + """Failed to retrieve the collection checksum.""" + + +class CollectionCreateError(CollectionError): + """Failed to create the collection.""" + + +class CollectionDeleteError(CollectionError): + """Failed to delete the collection""" + + +class CollectionRenameError(CollectionError): + """Failed to rename the collection.""" + + +class CollectionTruncateError(CollectionError): + """Failed to truncate the collection.""" + + +class CollectionLoadError(CollectionError): + """Failed to load the collection into memory.""" + + +class CollectionUnloadError(CollectionError): + """Failed to unload the collection from memory.""" + + +class CollectionRotateJournalError(CollectionError): + """Failed to rotate the journal of the collection.""" + + +class CollectionBadStatusError(CollectionError): + """Unknown status was returned from the collection.""" diff --git a/arango/exceptions/cursor.py b/arango/exceptions/cursor.py new file mode 100644 index 00000000..e92533c9 --- /dev/null +++ b/arango/exceptions/cursor.py @@ -0,0 +1,13 @@ +from arango.exceptions import ArangoError + + +class CursorError(ArangoError): + """Base class for errors in BaseCursor queries""" + + +class CursorNextError(CursorError): + """Failed to retrieve the next cursor result.""" + + +class CursorCloseError(CursorError): + """Failed to delete the cursor from the server.""" diff --git a/arango/exceptions/database.py b/arango/exceptions/database.py new file mode 100644 index 00000000..47428fd9 --- /dev/null +++ b/arango/exceptions/database.py @@ -0,0 +1,21 @@ +from arango.exceptions import ArangoError + + +class DatabaseError(ArangoError): + """Base class for errors in Database queries""" + + +class DatabaseListError(DatabaseError): + """Failed to retrieve the list of databases.""" + + +class DatabasePropertiesError(DatabaseError): + """Failed to retrieve the database options.""" + + +class DatabaseCreateError(DatabaseError): + """Failed to create the database.""" + + +class DatabaseDeleteError(DatabaseError): + """Failed to delete the database.""" diff --git a/arango/exceptions/document.py b/arango/exceptions/document.py new file mode 100644 index 00000000..0b0a1268 --- /dev/null +++ b/arango/exceptions/document.py @@ -0,0 +1,37 @@ +from arango.exceptions import ArangoError + + +class DocumentError(ArangoError): + """Base class for errors in Document queries""" + + +class DocumentCountError(DocumentError): + """Failed to retrieve the count of the documents in the collections.""" + + +class DocumentInError(DocumentError): + """Failed to check whether a collection contains a document.""" + + +class DocumentGetError(DocumentError): + """Failed to retrieve the document.""" + + +class DocumentInsertError(DocumentError): + """Failed to insert the document.""" + + +class DocumentReplaceError(DocumentError): + """Failed to replace the document.""" + + +class DocumentUpdateError(DocumentError): + """Failed to update the document.""" + + +class DocumentDeleteError(DocumentError): + """Failed to delete the document.""" + + +class DocumentRevisionError(DocumentError): + """The expected and actual document revisions do not match.""" diff --git a/arango/exceptions/graph.py b/arango/exceptions/graph.py new file mode 100644 index 00000000..894fb803 --- /dev/null +++ b/arango/exceptions/graph.py @@ -0,0 +1,61 @@ +from arango.exceptions import ArangoError + + +class GraphError(ArangoError): + """Base class for errors in Graph queries""" + + +class GraphListError(GraphError): + """Failed to retrieve the list of graphs.""" + + +class GraphGetError(GraphError): + """Failed to retrieve the graph.""" + + +class GraphCreateError(GraphError): + """Failed to create the graph.""" + + +class GraphDeleteError(GraphError): + """Failed to delete the graph.""" + + +class GraphPropertiesError(GraphError): + """Failed to retrieve the graph properties.""" + + +class GraphTraverseError(GraphError): + """Failed to execute the graph traversal.""" + + +class OrphanCollectionListError(GraphError): + """Failed to retrieve the list of orphaned vertex collections.""" + + +class VertexCollectionListError(GraphError): + """Failed to retrieve the list of vertex collections.""" + + +class VertexCollectionCreateError(GraphError): + """Failed to create the vertex collection.""" + + +class VertexCollectionDeleteError(GraphError): + """Failed to delete the vertex collection.""" + + +class EdgeDefinitionListError(GraphError): + """Failed to retrieve the list of edge definitions.""" + + +class EdgeDefinitionCreateError(GraphError): + """Failed to create the edge definition.""" + + +class EdgeDefinitionReplaceError(GraphError): + """Failed to replace the edge definition.""" + + +class EdgeDefinitionDeleteError(GraphError): + """Failed to delete the edge definition.""" diff --git a/arango/exceptions/index.py b/arango/exceptions/index.py new file mode 100644 index 00000000..24f5c3e0 --- /dev/null +++ b/arango/exceptions/index.py @@ -0,0 +1,17 @@ +from arango.exceptions import ArangoError + + +class IndexError(ArangoError): + """Base class for errors in Index queries""" + + +class IndexListError(IndexError): + """Failed to retrieve the list of indexes in the collection.""" + + +class IndexCreateError(IndexError): + """Failed to create the index in the collection.""" + + +class IndexDeleteError(IndexError): + """Failed to delete the index from the collection.""" diff --git a/arango/exceptions/job.py b/arango/exceptions/job.py new file mode 100644 index 00000000..55c0d214 --- /dev/null +++ b/arango/exceptions/job.py @@ -0,0 +1,5 @@ +from arango.exceptions import ArangoError + + +class JobResultError(ArangoError): + """Job response does not yet exist.""" diff --git a/arango/exceptions/pregel.py b/arango/exceptions/pregel.py new file mode 100644 index 00000000..c123a8e4 --- /dev/null +++ b/arango/exceptions/pregel.py @@ -0,0 +1,17 @@ +from arango.exceptions import ArangoError + + +class PregelError(ArangoError): + """Base class for errors in Pregel queries""" + + +class PregelJobCreateError(PregelError): + """Failed to start/create a Pregel job.""" + + +class PregelJobGetError(PregelError): + """Failed to retrieve a Pregel job.""" + + +class PregelJobDeleteError(PregelError): + """Failed to cancel/delete a Pregel job.""" diff --git a/arango/exceptions/server.py b/arango/exceptions/server.py new file mode 100644 index 00000000..2bbb8427 --- /dev/null +++ b/arango/exceptions/server.py @@ -0,0 +1,73 @@ +from arango.exceptions import ArangoError + + +class ServerError(ArangoError): + """Base class for Server errors""" + + +class ServerConnectionError(ServerError): + """Failed to connect to the ArangoDB instance.""" + + +class ServerEndpointsError(ServerError): + """Failed to retrieve the ArangoDB server endpoints.""" + + +class ServerVersionError(ServerError): + """Failed to retrieve the ArangoDB server version.""" + + +class ServerDetailsError(ServerError): + """Failed to retrieve the ArangoDB server details.""" + + +class ServerTimeError(ServerError): + """Failed to return the current ArangoDB system time.""" + + +class ServerEchoError(ServerError): + """Failed to return the last request.""" + + +class ServerSleepError(ServerError): + """Failed to suspend the ArangoDB server.""" + + +class ServerShutdownError(ServerError): + """Failed to initiate a clean shutdown sequence.""" + + +class ServerRunTestsError(ServerError): + """Failed to execute the specified tests on the server.""" + + +class ServerExecuteError(ServerError): + """Failed to execute a the given Javascript program on the server.""" + + +class ServerRequiredDBVersionError(ServerError): + """Failed to retrieve the required database version.""" + + +class ServerReadLogError(ServerError): + """Failed to retrieve the global log.""" + + +class ServerLogLevelError(ServerError): + """Failed to return the log level.""" + + +class ServerLogLevelSetError(ServerError): + """Failed to set the log level.""" + + +class ServerReloadRoutingError(ServerError): + """Failed to reload the routing information.""" + + +class ServerStatisticsError(ServerError): + """Failed to retrieve the server statistics.""" + + +class ServerRoleError(ServerError): + """Failed to retrieve the role of the server in a cluster.""" diff --git a/arango/exceptions/task.py b/arango/exceptions/task.py new file mode 100644 index 00000000..6c0f5ea6 --- /dev/null +++ b/arango/exceptions/task.py @@ -0,0 +1,21 @@ +from arango.exceptions import ArangoError + + +class TaskError(ArangoError): + """Base class for errors in Task queries""" + + +class TaskListError(TaskError): + """Failed to list the active server tasks.""" + + +class TaskGetError(TaskError): + """Failed to retrieve the active server task.""" + + +class TaskCreateError(TaskError): + """Failed to create a server task.""" + + +class TaskDeleteError(TaskError): + """Failed to delete a server task.""" diff --git a/arango/exceptions/transaction.py b/arango/exceptions/transaction.py new file mode 100644 index 00000000..61825571 --- /dev/null +++ b/arango/exceptions/transaction.py @@ -0,0 +1,5 @@ +from arango.exceptions import ArangoError + + +class TransactionError(ArangoError): + """Failed to execute a transaction.""" diff --git a/arango/exceptions/user.py b/arango/exceptions/user.py new file mode 100644 index 00000000..75f8ff85 --- /dev/null +++ b/arango/exceptions/user.py @@ -0,0 +1,41 @@ +from arango.exceptions import ArangoError + + +class UserError(ArangoError): + """Base class for errors in User queries""" + + +class UserListError(UserError): + """Failed to retrieve the users.""" + + +class UserGetError(UserError): + """Failed to retrieve the user.""" + + +class UserCreateError(UserError): + """Failed to create the user.""" + + +class UserUpdateError(UserError): + """Failed to update the user.""" + + +class UserReplaceError(UserError): + """Failed to replace the user.""" + + +class UserDeleteError(UserError): + """Failed to delete the user.""" + + +class UserAccessError(UserError): + """Failed to retrieve the names of databases user can access.""" + + +class UserGrantAccessError(UserError): + """Failed to grant user access to a database.""" + + +class UserRevokeAccessError(UserError): + """Failed to revoke user access to a database.""" diff --git a/arango/exceptions/wal.py b/arango/exceptions/wal.py new file mode 100644 index 00000000..61c82f03 --- /dev/null +++ b/arango/exceptions/wal.py @@ -0,0 +1,21 @@ +from arango.exceptions import ArangoError + + +class WALError(ArangoError): + """Base class for errors in WAL queries""" + + +class WALPropertiesError(WALError): + """Failed to retrieve the write-ahead log.""" + + +class WALConfigureError(WALError): + """Failed to configure the write-ahead log.""" + + +class WALTransactionListError(WALError): + """Failed to retrieve the list of running transactions.""" + + +class WALFlushError(WALError): + """Failed to flush the write-ahead log.""" diff --git a/arango/http_clients/__init__.py b/arango/http_clients/__init__.py index 615af1db..3907ac4f 100644 --- a/arango/http_clients/__init__.py +++ b/arango/http_clients/__init__.py @@ -1,6 +1,7 @@ import sys -from arango.http_clients.default import DefaultHTTPClient # noqa: F401 +from .base import BaseHTTPClient # noqa: F401 +from .default import DefaultHTTPClient # noqa: F401 if sys.version_info >= (3, 5): - from arango.http_clients.asyncio import AsyncioHTTPClient # noqa: F401 + from .asyncio import AsyncioHTTPClient # noqa: F401 diff --git a/arango/http_clients/asyncio.py b/arango/http_clients/asyncio.py index 445646b5..91b3588e 100644 --- a/arango/http_clients/asyncio.py +++ b/arango/http_clients/asyncio.py @@ -5,88 +5,83 @@ import threading import aiohttp +import time -from arango.http_clients.base import BaseHTTPClient -from arango.response import Response +from arango.http_clients import BaseHTTPClient +from arango.responses import LazyResponse -class FutureResponse(Response): # pragma: no cover - """Response for :class:`arango.http_clients.asyncio.AsyncioHTTPClient`. +# noinspection PyCompatibility +def start_event_loop(loop, request_queue, session_args): # pragma: no cover + """Start an event loop with an aiohttp session that pulls + from the request_queue + + :param loop: An event loop + :type loop: asyncio.BaseEventLoop + :param request_queue: The queue of tuples containing + :class:`arango.request.Request` and :class:`asyncio.Queue` + :type request_queue: asyncio.Queue + :param kwargs: the arguments to pass to the session instance + :type kwargs: dict + """ + asyncio.set_event_loop(loop) + future = loop.create_task(session_create(request_queue, **session_args)) + loop.run_until_complete(future) - :param future: The future instance. - :type future: concurrent.futures.Future + +# noinspection PyCompatibility +async def session_create(request_queue, **kwargs): + """Start an aiohttp session and pull from the job queue + + :param request_queue: The queue of :class:`arango.request.Request` + :type request_queue: asyncio.Queue + :param kwargs: the arguments to pass to the session instance + :type kwargs: dict """ - # noinspection PyMissingConstructor - def __init__(self, future): - self._future = future - - def __getattr__(self, item): - if item in self.__slots__: - response, text = self._future.result() - Response.__init__( - self, - method=response.method, - url=response.url, - headers=response.headers, - http_code=response.status, - http_text=response.reason, - body=text - ) - self.__getattr__ = None - else: - raise AttributeError - - -def start_event_loop(loop): # pragma: no cover - """Set the event loop and start it.""" - asyncio.set_event_loop(loop) - loop.run_forever() + async with aiohttp.ClientSession(**kwargs) as session: + this_thread = threading.current_thread() + while True: + request, output_queue = await request_queue.get() + if request is this_thread: + return + await output_queue.put(await session_send_request(request, + session)) # noinspection PyCompatibility -async def stop_event_loop(): # pragma: no cover - """Stop the event to allow it to exit.""" - asyncio.get_event_loop().stop() +async def session_send_request(request, session): + response = await session.request( + method=request.method, + **request.kwargs + ) + text = await response.text() + return response, text # noinspection PyCompatibility -async def make_async_request(method, - url, - params=None, - headers=None, - data=None, - auth=None): # pragma: no cover - """Asynchronously make a request using `aiohttp` library. - - :param method: HTTP method (e.g. ``"HEAD"``) - :type method: str | unicode - :param url: request method string - :type url: str | unicode - :param url: request URL - :type url: str | unicode - :param params: request parameters - :type params: dict - :param headers: request headers - :type headers: dict - :param data: request payload - :type data: str | unicode | dict - :param auth: username and password tuple - :type auth: tuple - :returns: ArangoDB HTTP response object and body - :rtype: aiohttp.ClientResponse +async def stop_event_loop(request_queue): # pragma: no cover + """Stop the request loop to allow it to exit.""" + await request_queue.put((threading.current_thread(), None)) + + +# noinspection PyCompatibility +async def make_async_request(request, request_queue, event_loop): + # pragma: no cover + """Asynchronously make a request using `aiohttp` library + + :param request: the request to make + :type request: arango.request.Request + :param request_queue: the request queue to submit to + :type request_queue: asyncio.Queue + :return: :class:`asyncio.Future` containing a tuple containing the + response to the request and its text. + :rtype: asyncio.Future """ - async with aiohttp.ClientSession() as session: - response = await session.request( - method=method, - url=url, - params=params, - headers=headers, - data=data, - auth=auth - ) - text = await response.text() - return response, text + + return_queue = asyncio.Queue(maxsize=1, loop=event_loop) + await request_queue.put((request, return_queue)) + return await return_queue.get() class AsyncioHTTPClient(BaseHTTPClient): # pragma: no cover @@ -95,12 +90,17 @@ class AsyncioHTTPClient(BaseHTTPClient): # pragma: no cover .. _aiohttp: http://aiohttp.readthedocs.io/en/stable/ """ - def __init__(self): - """Initialize the client.""" + def __init__(self, **kwargs): + """Initialize the client. + + :param kwargs: the arguments to pass to the aiohttp session + :type kwargs: dict + """ self._event_loop = asyncio.new_event_loop() + self._request_queue = asyncio.Queue(loop=self._event_loop) self._async_thread = threading.Thread( target=start_event_loop, - args=(self._event_loop,), + args=(self._event_loop, self._request_queue, kwargs), daemon=True ) self._async_thread.start() @@ -108,191 +108,55 @@ def __init__(self): def stop_client_loop(self): future = asyncio.run_coroutine_threadsafe( - stop_event_loop(), self._event_loop + stop_event_loop(self._request_queue), + self._event_loop ) asyncio.wait_for(future, self._timeout) + while self._event_loop.is_running(): + time.sleep(.01) + self._event_loop.close() self._async_thread.join() - def make_request(self, - method, - url, - params=None, - headers=None, - data=None, - auth=None): - """Make an asynchronous request and return a future response. - - :param method: HTTP method (e.g. ``"HEAD"``) - :type method: str | unicode - :param url: request method string - :type url: str | unicode - :param url: request URL - :type url: str | unicode - :param params: request parameters - :type params: dict - :param headers: request headers - :type headers: dict - :param data: request payload - :type data: str | unicode | dict - :param auth: username and password tuple - :type auth: tuple - :returns: ArangoDB HTTP response object - :rtype: arango.http_clients.asyncio.FutureResponse + @staticmethod + def response_mapper(response): + res, text = response + + outputs = {} + outputs["url"] = res.url + outputs["method"] = res.method + outputs["headers"] = res.headers + outputs["status_code"] = res.status + outputs["status_text"] = res.reason + outputs["body"] = text + + return outputs + + def make_request(self, request, response_mapper=None): + """Make an asynchronous request and return a lazy loading response. + + :param request: The request to make + :type request: arango.request.Request + :param response_mapper: Function that maps responses to a dictionary of + parameters to create an :class:`arango.responses.Response`. If + none, uses self.response_mapper. + :type response_mapper: callable + :return: The lazy loading response to this request + :rtype: arango.responses.LazyResponse """ - if isinstance(auth, tuple): - auth = aiohttp.BasicAuth(*auth) - if isinstance(params, dict): - for key, value in params.items(): + if response_mapper is None: + response_mapper = self.response_mapper + + if isinstance(request.auth, tuple): + request.auth = aiohttp.BasicAuth(*request.auth) + + if isinstance(request.params, dict): + for key, value in request.params.items(): if isinstance(value, bool): - params[key] = int(value) + request.params[key] = int(value) future = asyncio.run_coroutine_threadsafe( - make_async_request(method, url, params, headers, data, auth), + make_async_request(request, self._request_queue, self._event_loop), self._event_loop ) - return FutureResponse(future) - - def head(self, url, params=None, headers=None, auth=None): - """Execute an HTTP **HEAD** method. - - :param url: request URL - :type url: str | unicode - :param params: request parameters - :type params: dict - :param headers: request headers - :type headers: dict - :param auth: username and password tuple - :type auth: tuple - :returns: ArangoDB HTTP response object - :rtype: arango.response.FutureResponse - """ - return self.make_request( - method="HEAD", - url=url, - params=params, - headers=headers, - auth=auth - ) - - def get(self, url, params=None, headers=None, auth=None): - """Execute an HTTP **GET** method. - - :param url: request URL - :type url: str | unicode - :param params: request parameters - :type params: dict - :param headers: request headers - :type headers: dict - :param auth: username and password tuple - :type auth: tuple - :returns: ArangoDB HTTP response object - :rtype: arango.response.FutureResponse - """ - return self.make_request( - method="GET", - url=url, - params=params, - headers=headers, - auth=auth - ) - - def put(self, url, data, params=None, headers=None, auth=None): - """Execute an HTTP **PUT** method. - - :param url: request URL - :type url: str | unicode - :param data: request payload - :type data: str | unicode | dict - :param params: request parameters - :type params: dict - :param headers: request headers - :type headers: dict - :param auth: username and password tuple - :type auth: tuple - :returns: ArangoDB HTTP response object - :rtype: arango.response.FutureResponse - """ - return self.make_request( - method="PUT", - url=url, - data=data, - params=params, - headers=headers, - auth=auth - ) - - def post(self, url, data, params=None, headers=None, auth=None): - """Execute an HTTP **POST** method. - - :param url: request URL - :type url: str | unicode - :param data: request payload - :type data: str | unicode | dict - :param params: request parameters - :type params: dict - :param headers: request headers - :type headers: dict - :param auth: username and password tuple - :type auth: tuple - :returns: ArangoDB HTTP response object - :rtype: arango.response.FutureResponse - """ - return self.make_request( - method="POST", - url=url, - data=data, - params=params, - headers=headers, - auth=auth - ) - - def patch(self, url, data, params=None, headers=None, auth=None): - """Execute an HTTP **PATCH** method. - - :param url: request URL - :type url: str | unicode - :param data: request payload - :type data: str | unicode | dict - :param params: request parameters - :type params: dict - :param headers: request headers - :type headers: dict - :param auth: username and password tuple - :type auth: tuple - :returns: ArangoDB HTTP response object - :rtype: arango.response.FutureResponse - """ - return self.make_request( - method="PATCH", - url=url, - data=data, - params=params, - headers=headers, - auth=auth - ) - - def delete(self, url, data=None, params=None, headers=None, auth=None): - """Execute an HTTP **DELETE** method. - - :param url: request URL - :type url: str | unicode - :param data: request payload - :type data: str | unicode | dict - :param params: request parameters - :type params: dict - :param headers: request headers - :type headers: dict - :param auth: username and password tuple - :type auth: tuple - :returns: ArangoDB HTTP response object - :rtype: arango.response.FutureResponse - """ - return self.make_request( - method="DELETE", - url=url, - data=data, - params=params, - headers=headers, - auth=auth - ) + return LazyResponse(future, response_mapper) diff --git a/arango/http_clients/base.py b/arango/http_clients/base.py index d851ce13..af15419b 100644 --- a/arango/http_clients/base.py +++ b/arango/http_clients/base.py @@ -10,111 +10,17 @@ class BaseHTTPClient(object): # pragma: no cover __metaclass__ = ABCMeta @abstractmethod - def head(self, url, params=None, headers=None, auth=None): - """Execute an HTTP **HEAD** method. - - :param url: request URL - :type url: str | unicode - :param params: request parameters - :type params: dict - :param headers: request headers - :type headers: dict - :param auth: username and password tuple - :type auth: tuple - :returns: ArangoDB HTTP response object - :rtype: arango.response.Response + def make_request(self, request, response_mapper=None): + """Use the :class:arango.request.Request object to make an HTTP request + + :param request: The request to make + :type request: arango.request.Request + :param response_mapper: Function that maps responses to a dictionary of + parameters to create an :class:`arango.responses.Response`. If + none, uses self.response_mapper. + :type response_mapper: callable + :return: The response to this request + :rtype: arango.responses.BaseResponse """ - raise NotImplementedError - - @abstractmethod - def get(self, url, params=None, headers=None, auth=None): - """Execute an HTTP **GET** method. - - :param url: request URL - :type url: str | unicode - :param params: request parameters - :type params: dict - :param headers: request headers - :type headers: dict - :param auth: username and password tuple - :type auth: tuple - :returns: ArangoDB HTTP response object - :rtype: arango.response.Response - """ - raise NotImplementedError - @abstractmethod - def put(self, url, data, params=None, headers=None, auth=None): - """Execute an HTTP **PUT** method. - - :param url: request URL - :type url: str | unicode - :param data: request payload - :type data: str | unicode | dict - :param params: request parameters - :type params: dict - :param headers: request headers - :type headers: dict - :param auth: username and password tuple - :type auth: tuple - :returns: ArangoDB HTTP response object - :rtype: arango.response.Response - """ - raise NotImplementedError - - @abstractmethod - def post(self, url, data, params=None, headers=None, auth=None): - """Execute an HTTP **POST** method. - - :param url: request URL - :type url: str | unicode - :param data: request payload - :type data: str | unicode | dict - :param params: request parameters - :type params: dict - :param headers: request headers - :type headers: dict - :param auth: username and password tuple - :type auth: tuple - :returns: ArangoDB HTTP response object - :rtype: arango.response.Response - """ - raise NotImplementedError - - @abstractmethod - def patch(self, url, data, params=None, headers=None, auth=None): - """Execute an HTTP **PATCH** method. - - :param url: request URL - :type url: str | unicode - :param data: request payload - :type data: str | unicode | dict - :param params: request parameters - :type params: dict - :param headers: request headers - :type headers: dict - :param auth: username and password tuple - :type auth: tuple - :returns: ArangoDB HTTP response object - :rtype: arango.response.Response - """ - raise NotImplementedError - - @abstractmethod - def delete(self, url, data=None, params=None, headers=None, auth=None): - """Execute an HTTP **DELETE** method. - - :param url: request URL - :type url: str | unicode - :param data: request payload - :type data: str | unicode | dict - :param params: request parameters - :type params: dict - :param headers: request headers - :type headers: dict - :param auth: username and password tuple - :type auth: tuple - :returns: ArangoDB HTTP response object - :rtype: arango.response.Response - """ raise NotImplementedError diff --git a/arango/http_clients/default.py b/arango/http_clients/default.py index c0dbde0b..0196c9ae 100644 --- a/arango/http_clients/default.py +++ b/arango/http_clients/default.py @@ -2,8 +2,8 @@ import requests -from arango.response import Response -from arango.http_clients.base import BaseHTTPClient +from arango.responses import BaseResponse +from arango.http_clients import BaseHTTPClient class DefaultHTTPClient(BaseHTTPClient): @@ -20,194 +20,36 @@ def __init__(self, use_session=True, check_cert=True): self._session = requests self._check_cert = check_cert - def head(self, url, params=None, headers=None, auth=None): - """Execute an HTTP **HEAD** method. - - :param url: request URL - :type url: str | unicode - :param params: request parameters - :type params: dict - :param headers: request headers - :type headers: dict - :param auth: username and password tuple - :type auth: tuple - :returns: ArangoDB HTTP response object - :rtype: arango.response.Response - """ - res = self._session.head( - url=url, - params=params, - headers=headers, - auth=auth, - verify=self._check_cert - ) - return Response( - url=url, - method="head", - headers=res.headers, - http_code=res.status_code, - http_text=res.reason, - body=res.text - ) - - def get(self, url, params=None, headers=None, auth=None): - """Execute an HTTP **GET** method. - - :param url: request URL - :type url: str | unicode - :param params: request parameters - :type params: dict - :param headers: request headers - :type headers: dict - :param auth: username and password tuple - :type auth: tuple - :returns: ArangoDB HTTP response object - :rtype: arango.response.Response - """ - res = self._session.get( - url=url, - params=params, - headers=headers, - auth=auth, - verify=self._check_cert - ) - return Response( - url=url, - method="get", - headers=res.headers, - http_code=res.status_code, - http_text=res.reason, - body=res.text - ) - - def put(self, url, data, params=None, headers=None, auth=None): - """Execute an HTTP **PUT** method. - - :param url: request URL - :type url: str | unicode - :param data: request payload - :type data: str | unicode | dict - :param params: request parameters - :type params: dict - :param headers: request headers - :type headers: dict - :param auth: username and password tuple - :type auth: tuple - :returns: ArangoDB HTTP response object - :rtype: arango.response.Response + def make_request(self, request, response_mapper=None): + """Use the :class:arango.request.Request object to make an HTTP request + + :param request: The request to make + :type request: arango.request.Request + :param response_mapper: Function that maps responses to a dictionary of + parameters to create an :class:`arango.responses.Response`. If + none, uses self.response_mapper. + :type response_mapper: callable + :return: The response to this request + :rtype: arango.responses.BaseResponse """ - res = self._session.put( - url=url, - data=data, - params=params, - headers=headers, - auth=auth, - verify=self._check_cert - ) - return Response( - url=url, - method="put", - headers=res.headers, - http_code=res.status_code, - http_text=res.reason, - body=res.text - ) - def post(self, url, data, params=None, headers=None, auth=None): - """Execute an HTTP **POST** method. + if response_mapper is None: + response_mapper = self.response_mapper - :param url: request URL - :type url: str | unicode - :param data: request payload - :type data: str | unicode | dict - :param params: request parameters - :type params: dict - :param headers: request headers - :type headers: dict - :param auth: username and password tuple - :type auth: tuple - :returns: ArangoDB HTTP response object - :rtype: arango.response.Response - """ - res = self._session.post( - url=url, - data=data, - params=params, - headers=headers, - auth=auth, - verify=self._check_cert - ) - return Response( - url=url, - method="post", - headers=res.headers, - http_code=res.status_code, - http_text=res.reason, - body=res.text - ) + method = request.method - def patch(self, url, data, params=None, headers=None, auth=None): - """Execute an HTTP **PATCH** method. + res = self._session.request(method, **request.kwargs) + return BaseResponse(res, response_mapper) - :param url: request URL - :type url: str | unicode - :param data: request payload - :type data: str | unicode | dict - :param params: request parameters - :type params: dict - :param headers: request headers - :type headers: dict - :param auth: username and password tuple - :type auth: tuple - :returns: ArangoDB HTTP response object - :rtype: arango.response.Response - """ - res = self._session.patch( - url=url, - data=data, - params=params, - headers=headers, - auth=auth, - verify=self._check_cert - ) - return Response( - url=url, - method="patch", - headers=res.headers, - http_code=res.status_code, - http_text=res.reason, - body=res.text - ) + @staticmethod + def response_mapper(response): + outputs = {} - def delete(self, url, data=None, params=None, headers=None, auth=None): - """Execute an HTTP **DELETE** method. + outputs["url"] = response.url + outputs["method"] = response.request.method + outputs["headers"] = response.headers + outputs["status_code"] = response.status_code + outputs["status_text"] = response.reason + outputs["body"] = response.text - :param url: request URL - :type url: str | unicode - :param data: request payload - :type data: str | unicode | dict - :param params: request parameters - :type params: dict - :param headers: request headers - :type headers: dict - :param auth: username and password tuple - :type auth: tuple - :returns: ArangoDB HTTP response object - :rtype: arango.response.Response - """ - res = self._session.delete( - url=url, - data=data, - params=params, - headers=headers, - auth=auth, - verify=self._check_cert - ) - return Response( - url=url, - method="delete", - headers=res.headers, - http_code=res.status_code, - http_text=res.reason, - body=res.text - ) + return outputs diff --git a/arango/jobs/__init__.py b/arango/jobs/__init__.py new file mode 100644 index 00000000..85a06674 --- /dev/null +++ b/arango/jobs/__init__.py @@ -0,0 +1,4 @@ +from .base import BaseJob +from .async import AsyncJob +from .batch import BatchJob +from .old import OldJob diff --git a/arango/jobs/async.py b/arango/jobs/async.py new file mode 100644 index 00000000..8bf86b04 --- /dev/null +++ b/arango/jobs/async.py @@ -0,0 +1,230 @@ +from arango.jobs import BaseJob +from arango.utils import HTTP_OK +from arango.exceptions import ( + AsyncJobCancelError, + AsyncJobStatusError, + AsyncJobResultError, + AsyncJobClearError, + AsyncExecuteError, ArangoError) +from arango import Request + + +class AsyncJob(BaseJob): + """ArangoDB async job which holds the result of an API request. + + An async job tracks the status of a queued API request and its result. + + :param connection: ArangoDB database connection + :type connection: arango.connection.Connection + :param job_id: the ID of the async job + :type job_id: str | unicode + :param handler: the response handler + :type handler: callable + """ + + def __init__(self, handler, response=None, connection=None, + return_result=True): + BaseJob.__init__(self, handler, None, + job_id="ASYNC_NOT_YET_ASSIGNED", + job_type="asynchronous") + self._conn = connection + self._initial_response = response + self._result = None + + self._return_result = return_result + + if self._initial_response is None: + raise ValueError("AsyncJob must be instantiated with a " + "response.") + + if self._conn is None: + raise ValueError("AsyncJob must be instantiated with a " + "connection.") + + if self._conn.old_behavior: + self.id + + @property + def initial_response(self): + if self._initial_response.status_code not in HTTP_OK: + raise AsyncExecuteError(self._initial_response) + return self._initial_response + + @property + def id(self): + """Return the UUID of the job. + + :return: the UUID of the job + :rtype: str | unicode + """ + if self._job_id == "ASYNC_NOT_YET_ASSIGNED": + res = self.initial_response + + if self._return_result: + self._job_id = res.headers['x-arango-async-id'] + else: + self._job_id = None + + return self._job_id + + def status(self): + """Return the status of the async job from the server. + + :returns: the status of the async job, which can be ``"pending"`` (the + job is still in the queue), ``"done"`` (the job finished or raised + an exception), or `"cancelled"` (the job was cancelled before + completion) + :rtype: str | unicode + :raises arango.exceptions.AsyncJobStatusError: if the status of the + async job cannot be retrieved from the server + """ + + request = Request( + method='get', + url='/_api/job/{}'.format(self.id) + ) + + def handler(res): + if res.status_code == 204: + self.update('pending') + elif res.status_code in HTTP_OK: + self.update('done') + elif res.status_code == 404: + raise AsyncJobStatusError(res, + 'Job {} missing'.format(self.id)) + else: + raise AsyncJobStatusError(res) + + return self._status + + response = self._conn.underlying.handle_request(request, handler, + job_class=BaseJob) + + return response.result(raise_errors=True) + + def result(self, raise_errors=False): + """Return the result of the async job if available. + + :returns: the result or the exception from the async job + :rtype: object + :raises arango.exceptions.AsyncJobResultError: if the result of the + async job cannot be retrieved from the server + + .. note:: + An async job result will automatically be cleared from the server + once fetched and will *not* be available in subsequent calls. + """ + + if not self._return_result: + return None + + if self._result is None or \ + isinstance(self._result, BaseException): + request = Request( + method='put', + url='/_api/job/{}'.format(self.id) + ) + + def handler(res): + if res.status_code == 204: + raise AsyncJobResultError( + 'Job {} not done'.format(self.id)) + elif res.status_code in HTTP_OK: + self.update('done', res) + elif res.status_code == 404: + raise AsyncJobResultError(res, 'Job {} missing'.format( + self.id)) + else: + raise AsyncJobResultError(res) + + if ('X-Arango-Async-Id' in res.headers + or 'x-arango-async-id' in res.headers): + return self._handler(res) + + try: + self._result = self._conn.underlying.handle_request( + request, + handler, + job_class=BaseJob + ).result(raise_errors=True) + + except ArangoError as err: + self.update('error') + self._result = err + + if raise_errors: + if isinstance(self._result, BaseException): + raise self._result + + return self._result + + def cancel(self, ignore_missing=False): # pragma: no cover + """Cancel the async job if it is still pending. + + :param ignore_missing: ignore missing async jobs + :type ignore_missing: bool + :returns: ``True`` if the job was cancelled successfully, ``False`` if + the job was not found but **ignore_missing** was set to ``True`` + :rtype: bool + :raises arango.exceptions.AsyncJobCancelError: if the async job cannot + be cancelled + + .. note:: + An async job cannot be cancelled once it is taken out of the queue + (i.e. started, finished or cancelled). + """ + + request = Request( + method='put', + url='/_api/job/{}/cancel'.format(self.id) + ) + + def handler(res): + if res.status_code == 200: + self.update("cancelled") + return True + elif res.status_code == 404: + if ignore_missing: + return False + raise AsyncJobCancelError(res, + 'Job {} missing'.format(self.id)) + else: + raise AsyncJobCancelError(res) + + response = self._conn.underlying.handle_request(request, handler, + job_class=BaseJob) + + return response.result(raise_errors=True) + + def clear(self, ignore_missing=False): + """Delete the result of the job from the server. + + :param ignore_missing: ignore missing async jobs + :type ignore_missing: bool + :returns: ``True`` if the result was deleted successfully, ``False`` + if the job was not found but **ignore_missing** was set to ``True`` + :rtype: bool + :raises arango.exceptions.AsyncJobClearError: if the result of the + async job cannot be delete from the server + """ + + request = Request( + method='delete', + url='/_api/job/{}'.format(self.id) + ) + + def handler(res): + if res.status_code in HTTP_OK: + return True + elif res.status_code == 404: + if ignore_missing: + return False + raise AsyncJobClearError(res, + 'Job {} missing'.format(self.id)) + else: + raise AsyncJobClearError(res) + + response = self._conn.underlying.handle_request(request, handler, + job_class=BaseJob) + + return response.result(raise_errors=True) diff --git a/arango/jobs/base.py b/arango/jobs/base.py new file mode 100644 index 00000000..86c95924 --- /dev/null +++ b/arango/jobs/base.py @@ -0,0 +1,83 @@ +from uuid import uuid4 + +from arango.exceptions import JobResultError, ArangoError + + +class BaseJob(object): + """ArangoDB job which holds the result of an API request. + + A job tracks the status of an API request and its result. + """ + + def __init__(self, handler, response=None, job_id=None, job_type="base"): + if job_id is None: + job_id = uuid4().hex + + self._handler = handler + self._job_id = job_id + self._status = 'pending' + self._response = response + self._result = None + self._job_type = job_type + + def __repr__(self): + return ''.format(self._job_type, self._job_id) + + @property + def id(self): + """Return the UUID of the job. + + :return: the UUID of the job + :rtype: str | unicode + """ + return self._job_id + + def update(self, status, response=None): + """Update the status and the response of the job. + + This method designed to be used internally only. + + :param status: the status of the job + :type status: str + :param response: the response to the job + :type response: arango.responses.base.BaseResponse + """ + + self._status = status + + if response is not None: + self._response = response + + def status(self): + """Return the status of the job. + + :returns: the batch job status, which can be ``"pending"`` (the job is + still waiting to be committed), ``"done"`` (the job completed) or + ``"error"`` (the job raised an exception) + :rtype: str | unicode + """ + return self._status + + def result(self, raise_errors=False): + """Return the result of the job or its error. + + :param raise_errors: whether to raise this result if it is an error + :type raise_errors: bool + :returns: the result of the batch job if the job is successful + :rtype: object + """ + if self._response is None: + raise JobResultError("Job with type {} does not have a response " + "assigned to it.".format(self._job_type)) + + if self._result is None: + try: + self._result = self._handler(self._response) + except ArangoError as err: + self.update('error') + self._result = err + + if raise_errors: + raise err + + return self._result diff --git a/arango/jobs/batch.py b/arango/jobs/batch.py new file mode 100644 index 00000000..0dfea44e --- /dev/null +++ b/arango/jobs/batch.py @@ -0,0 +1,49 @@ +from arango.utils.lock import RLock +from arango.jobs import BaseJob + + +class BatchJob(BaseJob): + """ArangoDB batch job which holds the result of an API request. + + A batch job tracks the status of a queued API request and its result. + """ + + def __init__(self, handler, job_id=None, response=None): + BaseJob.__init__(self, handler, job_id=job_id, response=response, + job_type="batch") + self._lock = RLock() + + def update(self, status, response=None): + """Update the status and the response of the batch job. + + This method designed to be used internally only. + + :param status: the status of the job + :type status: str + :param response: the response to the job + :type response: arango.responses.base.BaseResponse + """ + + with self._lock: + return BaseJob.update(self, status, response) + + def status(self): + """Return the status of the batch job. + + :returns: the batch job status, which can be ``"pending"`` (the job is + still waiting to be committed), ``"done"`` (the job completed) or + ``"error"`` (the job raised an exception) + :rtype: str | unicode + """ + with self._lock: + return BaseJob.status(self) + + def result(self, raise_errors=False): + """Return the result of the job or its error. + + :returns: the result of the batch job if the job is successful + :rtype: object + :raises ArangoError: if the batch job failed + """ + with self._lock: + return BaseJob.result(self) diff --git a/arango/jobs/old.py b/arango/jobs/old.py new file mode 100644 index 00000000..bd39565c --- /dev/null +++ b/arango/jobs/old.py @@ -0,0 +1,10 @@ +from arango.jobs import BaseJob + + +class OldJob(BaseJob): + def __new__(cls, handler, response=None, job_id=None): + job = BaseJob(handler, response=response, job_id=job_id) + + res = job.result(raise_errors=True) + + return res diff --git a/arango/lock.py b/arango/lock.py deleted file mode 100644 index e6e65cc0..00000000 --- a/arango/lock.py +++ /dev/null @@ -1,36 +0,0 @@ -import six - - -if six.PY2: - import Queue - - - class Lock(object): - """Implementation of a lock with a timeout for python 2. - - https://stackoverflow.com/questions/35149889/lock-with-timeout-in-python2-7 - """ - - def __init__(self): - self._queue = Queue.Queue(maxsize=1) - self._queue.put(True, block=False) - - def acquire(self, timeout=-1): - if timeout <= 0: - timeout = None - - try: - return self._queue.get(block=True, timeout=timeout) - except Queue.Empty: - return False - - def release(self): - self._queue.put(True, block=False) - - def __enter__(self): - self.acquire() - - def __exit__(self, exc_type, exc_val, exc_tb): - self.release() -else: - from threading import Lock diff --git a/arango/request.py b/arango/request.py index 38752a6c..8b8e3192 100644 --- a/arango/request.py +++ b/arango/request.py @@ -14,38 +14,42 @@ class Request(object): __slots__ = ( 'method', - 'endpoint', + 'url', 'headers', 'params', 'data', 'command', + 'auth' ) def __init__(self, method, - endpoint, + url, headers=None, params=None, data=None, - command=None): + command=None, + auth=None): self.method = method - self.endpoint = endpoint + self.url = url self.headers = headers or {} self.params = params or {} self.data = data self.command = command + self.auth = auth @property def kwargs(self): return { - 'endpoint': self.endpoint, + 'url': self.url, 'headers': self.headers, 'params': self.params, 'data': self.data, + 'auth': self.auth } def stringify(self): - path = self.endpoint + path = self.url if self.params is not None: path += "?" + moves.urllib.parse.urlencode(self.params) request_string = "{} {} HTTP/1.1".format(self.method, path) diff --git a/arango/responses/__init__.py b/arango/responses/__init__.py new file mode 100644 index 00000000..e436fe4d --- /dev/null +++ b/arango/responses/__init__.py @@ -0,0 +1,2 @@ +from .base import BaseResponse +from .lazy import LazyResponse diff --git a/arango/response.py b/arango/responses/base.py similarity index 63% rename from arango/response.py rename to arango/responses/base.py index 535cf832..24904dd4 100644 --- a/arango/response.py +++ b/arango/responses/base.py @@ -1,7 +1,7 @@ import json -class Response(object): +class BaseResponse(object): """ArangoDB HTTP response. Overridden methods of :class:`arango.http_clients.base.BaseHTTPClient` must @@ -30,42 +30,41 @@ class Response(object): 'headers', 'status_code', 'status_text', - 'raw_body', 'body', + 'raw_body', 'error_code', - 'error_message' + 'error_message', + 'is_json' ) def __init__(self, - method=None, - url=None, - headers=None, - http_code=None, - http_text=None, - body=None): - self.url = url - self.method = method - self.headers = headers - self.status_code = http_code - self.status_text = http_text + response, + response_mapper): + + processed = response_mapper(response) + self.method = processed.get("method", None) + self.url = processed.get("url", None) + self.headers = processed.get("headers", None) + self.status_code = processed.get("status_code", None) + self.status_text = processed.get("status_text", None) + + self.raw_body = None + self.body = None + self.error_code = None + self.error_message = None + + self.update_body(processed.get("body", None)) + + def update_body(self, body): self.raw_body = body + try: - self.body = json.loads(body) + self.body = json.loads(self.raw_body) except (ValueError, TypeError): - self.body = body - if self.body and isinstance(self.body, dict): + self.body = self.raw_body + if isinstance(self.body, dict): self.error_code = self.body.get('errorNum') self.error_message = self.body.get('errorMessage') else: self.error_code = None self.error_message = None - - def update_body(self, new_body): - return Response( - url=self.url, - method=self.method, - headers=self.headers, - http_code=self.status_code, - http_text=self.status_text, - body=new_body - ) diff --git a/arango/responses/lazy.py b/arango/responses/lazy.py new file mode 100644 index 00000000..e2891734 --- /dev/null +++ b/arango/responses/lazy.py @@ -0,0 +1,32 @@ +from arango.responses import BaseResponse + + +class LazyResponse(BaseResponse): # pragma: no cover + """Response which lazily loads all values from some future. + + :param future: The future instance which has the function result()" + :type future: :method:result() + :param response_mapper: the callable which maps the result to a standard + dictionary of fields + :type response_mapper: callable + """ + + # noinspection PyMissingConstructor + def __init__(self, future, response_mapper): + self._future = future + self._response_mapper = response_mapper + + def __getattr__(self, item): + if item in self.__slots__: + future_result = self._future.result() + BaseResponse.__init__( + self, + future_result, + self._response_mapper + ) + self.__getattr__ = None + self._response_mapper = None + self._future = None + return getattr(self, item) + else: + raise AttributeError diff --git a/arango/utils/__init__.py b/arango/utils/__init__.py new file mode 100644 index 00000000..e4c2ac7b --- /dev/null +++ b/arango/utils/__init__.py @@ -0,0 +1,3 @@ +from .constants import * +from .functions import * +from .lock import * diff --git a/arango/utils/constants.py b/arango/utils/constants.py new file mode 100644 index 00000000..d52004d4 --- /dev/null +++ b/arango/utils/constants.py @@ -0,0 +1,5 @@ +VERSION = '3.13.0' + +# Set of HTTP OK status codes +HTTP_OK = {200, 201, 202, 203, 204, 205, 206} +HTTP_AUTH_ERR = {401, 403} diff --git a/arango/utils.py b/arango/utils/functions.py similarity index 51% rename from arango/utils.py rename to arango/utils/functions.py index 89fa6034..940a42c6 100644 --- a/arango/utils.py +++ b/arango/utils/functions.py @@ -4,10 +4,6 @@ from six import string_types -# Set of HTTP OK status codes -HTTP_OK = {200, 201, 202, 203, 204, 205, 206} -HTTP_AUTH_ERR = {401, 403} - def sanitize(data): if data is None: @@ -16,3 +12,18 @@ def sanitize(data): return data else: return dumps(data) + + +def fix_params(params): + if params is None: + return params + + outparams = {} + + for param, value in params.items(): + if isinstance(value, bool): + value = int(value) + + outparams[param] = value + + return outparams diff --git a/arango/utils/lock.py b/arango/utils/lock.py new file mode 100644 index 00000000..a3d0b8e4 --- /dev/null +++ b/arango/utils/lock.py @@ -0,0 +1,67 @@ +import six +from threading import current_thread + + +if six.PY2: + import Queue + + + class Lock(object): + """Implementation of a lock with a timeout for python 2. + + https://stackoverflow.com/questions/35149889/lock-with-timeout-in-python2-7 + """ + + def __init__(self): + self._queue = Queue.Queue(maxsize=1) + self._queue.put(True, block=False) + + def acquire(self, timeout=-1): + if timeout <= 0: + timeout = None + + try: + return self._queue.get(block=True, timeout=timeout) + except Queue.Empty: + return False + + def release(self): + self._queue.put(True, block=False) + + def __enter__(self): + self.acquire() + + def __exit__(self, exc_type, exc_val, exc_tb): + self.release() + + + class RLock(Lock): + """Implementation of a reentrant lock with a timeout""" + + def __init__(self): + super(RLock, self).__init__() + self._thread_id = None + self._current_thread_count = 0 + + def acquire(self, timeout=-1): + if current_thread() is self._thread_id: + self._current_thread_count += 1 + return True + else: + res = Lock.acquire(self, timeout) + self._thread_id = current_thread() + self._current_thread_count = 1 + return res + + def release(self): + if current_thread() is self._thread_id: + self._current_thread_count -= 1 + if self._current_thread_count == 0: + self._thread_id = None + Lock.release(self) + else: + raise RuntimeError("Tried to release a lock which was not " + "owned by this thread.") + +else: + from threading import Lock, RLock diff --git a/arango/version.py b/arango/version.py deleted file mode 100644 index 300ab248..00000000 --- a/arango/version.py +++ /dev/null @@ -1 +0,0 @@ -VERSION = '3.13.0' diff --git a/out.txt b/out.txt new file mode 100644 index 00000000..9b903eeb --- /dev/null +++ b/out.txt @@ -0,0 +1,67 @@ +./arango/responses/__init__.py:1:1: F401 '.base.BaseResponse' imported but unused +./arango/responses/__init__.py:2:1: F401 '.lazy.LazyResponse' imported but unused +./arango/utils/__init__.py:1:1: F403 'from .constants import *' used; unable to detect undefined names +./arango/utils/__init__.py:1:1: F401 '.constants.*' imported but unused +./arango/utils/__init__.py:2:1: F403 'from .functions import *' used; unable to detect undefined names +./arango/utils/__init__.py:2:1: F401 '.functions.*' imported but unused +./arango/utils/__init__.py:3:1: F403 'from .lock import *' used; unable to detect undefined names +./arango/utils/__init__.py:3:1: F401 '.lock.*' imported but unused +./arango/utils/lock.py:9:5: E303 too many blank lines (2) +./arango/utils/lock.py:38:5: E303 too many blank lines (2) +./arango/exceptions/__init__.py:1:1: F401 '.base.ArangoError' imported but unused +./arango/exceptions/__init__.py:2:1: F403 'from .aql import *' used; unable to detect undefined names +./arango/exceptions/__init__.py:2:1: F401 '.aql.*' imported but unused +./arango/exceptions/__init__.py:3:1: F403 'from .async import *' used; unable to detect undefined names +./arango/exceptions/__init__.py:3:1: F401 '.async.*' imported but unused +./arango/exceptions/__init__.py:4:1: F403 'from .batch import *' used; unable to detect undefined names +./arango/exceptions/__init__.py:4:1: F401 '.batch.*' imported but unused +./arango/exceptions/__init__.py:5:1: F403 'from .collection import *' used; unable to detect undefined names +./arango/exceptions/__init__.py:5:1: F401 '.collection.*' imported but unused +./arango/exceptions/__init__.py:6:1: F403 'from .cursor import *' used; unable to detect undefined names +./arango/exceptions/__init__.py:6:1: F401 '.cursor.*' imported but unused +./arango/exceptions/__init__.py:7:1: F403 'from .database import *' used; unable to detect undefined names +./arango/exceptions/__init__.py:7:1: F401 '.database.*' imported but unused +./arango/exceptions/__init__.py:8:1: F403 'from .document import *' used; unable to detect undefined names +./arango/exceptions/__init__.py:8:1: F401 '.document.*' imported but unused +./arango/exceptions/__init__.py:9:1: F403 'from .graph import *' used; unable to detect undefined names +./arango/exceptions/__init__.py:9:1: F401 '.graph.*' imported but unused +./arango/exceptions/__init__.py:10:1: F403 'from .index import *' used; unable to detect undefined names +./arango/exceptions/__init__.py:10:1: F401 '.index.*' imported but unused +./arango/exceptions/__init__.py:11:1: F403 'from .job import *' used; unable to detect undefined names +./arango/exceptions/__init__.py:11:1: F401 '.job.*' imported but unused +./arango/exceptions/__init__.py:12:1: F403 'from .pregel import *' used; unable to detect undefined names +./arango/exceptions/__init__.py:12:1: F401 '.pregel.*' imported but unused +./arango/exceptions/__init__.py:13:1: F403 'from .server import *' used; unable to detect undefined names +./arango/exceptions/__init__.py:13:1: F401 '.server.*' imported but unused +./arango/exceptions/__init__.py:14:1: F403 'from .task import *' used; unable to detect undefined names +./arango/exceptions/__init__.py:14:1: F401 '.task.*' imported but unused +./arango/exceptions/__init__.py:15:1: F403 'from .transaction import *' used; unable to detect undefined names +./arango/exceptions/__init__.py:15:1: F401 '.transaction.*' imported but unused +./arango/exceptions/__init__.py:16:1: F403 'from .user import *' used; unable to detect undefined names +./arango/exceptions/__init__.py:16:1: F401 '.user.*' imported but unused +./arango/exceptions/__init__.py:17:1: F403 'from .wal import *' used; unable to detect undefined names +./arango/exceptions/__init__.py:17:1: F401 '.wal.*' imported but unused +./arango/connections/__init__.py:1:1: F401 '.base.BaseConnection' imported but unused +./arango/connections/__init__.py:2:1: F401 'arango.connections.executions.async.AsyncExecution' imported but unused +./arango/connections/__init__.py:3:1: F401 'arango.connections.executions.batch.BatchExecution' imported but unused +./arango/connections/__init__.py:4:1: F401 'arango.connections.executions.cluster.ClusterTest' imported but unused +./arango/connections/__init__.py:5:1: F401 'arango.connections.executions.transaction.TransactionExecution' imported but unused +./arango/connections/executions/__init__.py:1:1: F401 '.async.AsyncExecution' imported but unused +./arango/connections/executions/__init__.py:2:1: F401 '.batch.BatchExecution' imported but unused +./arango/connections/executions/__init__.py:3:1: F401 '.cluster.ClusterTest' imported but unused +./arango/connections/executions/__init__.py:4:1: F401 '.transaction.TransactionExecution' imported but unused +./arango/api/__init__.py:1:1: F401 '.api.APIWrapper' imported but unused +./arango/api/wrappers/__init__.py:1:1: F401 '.aql.AQL' imported but unused +./arango/api/wrappers/__init__.py:2:1: F401 '.graph.Graph' imported but unused +./arango/api/databases/__init__.py:1:1: F401 '.base.BaseDatabase' imported but unused +./arango/api/databases/__init__.py:2:1: F401 '.system.SystemDatabase' imported but unused +./arango/api/databases/base.py:247:1: W293 blank line contains whitespace +./arango/api/collections/__init__.py:1:1: F401 '.base.BaseCollection' imported but unused +./arango/api/collections/__init__.py:2:1: F401 '.standard.Collection' imported but unused +./arango/api/collections/__init__.py:4:1: F401 '.vertex.VertexCollection' imported but unused +./arango/api/cursors/__init__.py:1:1: F401 '.base.BaseCursor' imported but unused +./arango/api/cursors/__init__.py:2:1: F401 '.export.ExportCursor' imported but unused +./arango/jobs/__init__.py:1:1: F401 '.base.BaseJob' imported but unused +./arango/jobs/__init__.py:2:1: F401 '.async.AsyncJob' imported but unused +./arango/jobs/__init__.py:3:1: F401 '.batch.BatchJob' imported but unused +./arango/jobs/__init__.py:4:1: F401 '.old.OldJob' imported but unused diff --git a/scripts/setup_arangodb_mac.sh b/scripts/setup_arangodb_mac.sh index f7e770ec..f6615a51 100755 --- a/scripts/setup_arangodb_mac.sh +++ b/scripts/setup_arangodb_mac.sh @@ -15,7 +15,7 @@ fi PID=$(echo $PPID) TMP_DIR="/tmp/arangodb.$PID" PID_FILE="/tmp/arangodb.$PID.pid" -ARANGODB_DIR="/usr/local/opt/arangodb" +ARANGODB_DIR="s" ARANGOD="${ARANGODB_DIR}/sbin/arangod" echo "Creating temporary database directory ..." diff --git a/setup.py b/setup.py index b72c1ad5..08f8b7e7 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ import sys version = {} -with open('./arango/version.py') as fp: +with open('./arango/utils/constants.py') as fp: exec(fp.read(), version) if sys.version_info < (3, 5): diff --git a/tests/test_aql.py b/tests/test_aql.py index d306ada9..47a28776 100644 --- a/tests/test_aql.py +++ b/tests/test_aql.py @@ -3,7 +3,7 @@ import pytest from arango import ArangoClient -from arango.aql import AQL +from arango.api.wrappers import AQL from arango.exceptions import ( AsyncExecuteError, BatchExecuteError, @@ -33,8 +33,9 @@ db.create_collection(col_name) username = generate_user_name() user = arango_client.create_user(username, 'password') -func_name = '' -func_body = '' +func_prefix = 'myfunctions' +func_name = 'myfunctions::temperature::celsiustofahrenheit' +func_body = 'function (celsius) { return celsius * 1.8 + 32; }' def teardown_module(*_): @@ -139,8 +140,6 @@ def test_query_function_create_and_list(): global func_name, func_body assert db.aql.functions() == {} - func_name = 'myfunctions::temperature::celsiustofahrenheit' - func_body = 'function (celsius) { return celsius * 1.8 + 32; }' # Test create AQL function db.aql.create_function(func_name, func_body) @@ -151,10 +150,9 @@ def test_query_function_create_and_list(): assert db.aql.functions() == {func_name: func_body} # Test create invalid AQL function - func_body = 'function (celsius) { invalid syntax }' + bad_body = 'function (celsius) { invalid syntax }' with pytest.raises(AQLFunctionCreateError): - result = db.aql.create_function(func_name, func_body) - assert result is True + print(db.aql.create_function(func_name, bad_body)) @pytest.mark.order6 @@ -163,6 +161,12 @@ def test_query_function_delete_and_list(): result = db.aql.delete_function(func_name) assert result is True + # Test delete AQL function with namespace prefix + # TODO figure this out + db.aql.create_function(func_name+"q", func_body) + result = db.aql.delete_function(func_prefix, group=True) + assert result is True + # Test delete missing AQL function with pytest.raises(AQLFunctionDeleteError): db.aql.delete_function(func_name) diff --git a/tests/test_async.py b/tests/test_async.py index ef34898b..59617925 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -6,17 +6,17 @@ from six import string_types from arango import ArangoClient -from arango.aql import AQL -from arango.collections.standard import Collection +from arango.api.wrappers.aql import AQL +from arango.api.collections import Collection from arango.exceptions import ( AsyncExecuteError, AsyncJobClearError, AsyncJobResultError, AsyncJobStatusError, AsyncJobListError, - AQLQueryExecuteError ) -from arango.graph import Graph +from arango.api.wrappers.graph import Graph +from arango.jobs import AsyncJob from tests.utils import ( generate_db_name, @@ -40,8 +40,9 @@ def setup_function(*_): def wait_on_job(job): - while job.status() == 'pending': - pass + # TODO CHANGED to allow errored out results to process again, added sleep + while job.status() != 'done': + sleep(.01) @pytest.mark.order1 @@ -84,7 +85,8 @@ def test_async_inserts_without_result(): # Ensure that no jobs were returned for job in [job1, job2, job3]: - assert job is None + # TODO CHANGED + assert job.result() is None # Ensure that the asynchronously requests went through sleep(0.5) @@ -99,16 +101,21 @@ def test_async_inserts_with_result(): # Test precondition assert len(col) == 0 + # TODO CHANGED Race condition, added more docs + num_docs = 50000 + # Insert test documents asynchronously with return_result True async_col = db.asynchronous(return_result=True).collection(col_name) - test_docs = [{'_key': str(i), 'val': str(i * 42)} for i in range(10000)] + test_docs = [{'_key': str(i), 'val': str(i * 42)} for i in range(num_docs)] job1 = async_col.insert_many(test_docs, sync=True) job2 = async_col.insert_many(test_docs, sync=True) job3 = async_col.insert_many(test_docs, sync=True) + job4 = async_col.insert_many(test_docs, sync=True) + job5 = async_col.insert_many(test_docs, sync=True) # Test get result from a pending job with pytest.raises(AsyncJobResultError) as err: - job3.result() + job3.result(raise_errors=True) assert 'Job {} not done'.format(job3.id) in err.value.message # Test get result from finished but with existing jobs @@ -116,22 +123,40 @@ def test_async_inserts_with_result(): assert 'ArangoDB asynchronous job {}'.format(job.id) in repr(job) assert isinstance(job.id, string_types) wait_on_job(job) - assert len(job.result()) == 10000 + print(job.result()) + assert len(job.result(raise_errors=True)) == num_docs # Test get result from missing jobs - for job in [job1, job2, job3]: - with pytest.raises(AsyncJobResultError) as err: - job.result() - assert 'Job {} missing'.format(job.id) in err.value.message + # TODO CHANGED removed due to enforcement of constant behavior of jobs + # for job in [job1, job2, job3]: + # with pytest.raises(AsyncJobResultError) as err: + # job.result() + # assert 'Job {} missing'.format(job.id) in err.value.message + + # TODO CHANGED reordered + # Retrieve the results of the jobs + assert len(col) == num_docs # Test get result without authentication - setattr(getattr(job1, '_conn'), '_password', 'incorrect') + # TODO CHANGED This originally failed with for a different reason- the + # result had already been calculated. A fourth job has been created to + # test this. + + bad_db = arango_client.db( + name=db_name, + username='root', + password='incorrect' + ) + + job4._conn = bad_db._conn with pytest.raises(AsyncJobResultError) as err: - job.result() + job4.result(raise_errors=True) assert '401' in err.value.message - # Retrieve the results of the jobs - assert len(col) == 10000 + # Test get result that does not exist + job5._job_id = "BADJOBID" + with pytest.raises(AsyncJobResultError): + job5.result(raise_errors=True) @pytest.mark.order5 @@ -144,10 +169,12 @@ def test_async_query(): {'_key': '3', 'val': 3}, ])) + # TODO CHANGED removed due to enforcement of constant behavior of async + # job errors # Test asynchronous execution of an invalid AQL query job = asynchronous.aql.execute('THIS IS AN INVALID QUERY') wait_on_job(job) - assert isinstance(job.result(), AQLQueryExecuteError) + assert isinstance(job.result(), AsyncJobResultError) # Test asynchronous execution of a valid AQL query job = asynchronous.aql.execute( @@ -190,7 +217,13 @@ def test_async_get_status(): assert 'Job {} missing'.format(job.id) in err.value.message # Test get status without authentication - setattr(getattr(job, '_conn'), '_password', 'incorrect') + bad_db = arango_client.db( + name=db_name, + username='root', + password='incorrect' + ) + + job._conn = bad_db._conn with pytest.raises(AsyncJobStatusError) as err: job.status() assert 'HTTP 401' in err.value.message @@ -254,7 +287,13 @@ def test_clear_async_job(): assert job.clear(ignore_missing=True) is False # Test clear without authentication - setattr(getattr(job1, '_conn'), '_password', 'incorrect') + bad_db = arango_client.db( + name=db_name, + username='root', + password='incorrect' + ) + + job1._conn = bad_db._conn with pytest.raises(AsyncJobClearError) as err: job1.clear(ignore_missing=False) assert 'HTTP 401' in err.value.message @@ -408,3 +447,13 @@ def test_list_async_jobs_db_level(): job_ids = db.async_jobs(status='done', count=1) assert len(job_ids) == 1 assert job_ids[0] in expected_job_ids + + +def test_async_job_failure(): + with pytest.raises(ValueError): + # test failure on missing response + AsyncJob(lambda *args, **kwargs: True) + + with pytest.raises(ValueError): + # test failure on missing connection + AsyncJob(lambda *args, **kwargs: True, response="not null") diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 9fcdc10f..c625d590 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -6,7 +6,7 @@ from six import string_types from arango import ArangoClient -from arango.database import Database +from arango.api.databases.base import BaseDatabase from arango.exceptions import ( DatabaseCreateError, DatabaseDeleteError, @@ -193,11 +193,11 @@ def test_database_management(): # Test create database result = arango_client.create_database(db_name) - assert isinstance(result, Database) + assert isinstance(result, BaseDatabase) assert db_name in arango_client.databases() # Test get after create database - assert isinstance(arango_client.db(db_name), Database) + assert isinstance(arango_client.db(db_name), BaseDatabase) assert arango_client.db(db_name).name == db_name # Test create duplicate database diff --git a/tests/test_batch.py b/tests/test_batch.py index 7f088c9d..c24843c7 100644 --- a/tests/test_batch.py +++ b/tests/test_batch.py @@ -1,21 +1,20 @@ from __future__ import absolute_import, unicode_literals -from uuid import UUID - import pytest from threading import Thread, Event import time from arango import ArangoClient -from arango.aql import AQL -from arango.collections.standard import Collection +from arango.api.wrappers.aql import AQL +from arango.api.collections import Collection from arango.exceptions import ( DocumentRevisionError, DocumentInsertError, - BatchExecuteError + BatchExecuteError, + JobResultError ) -from arango.graph import Graph +from arango.api.wrappers.graph import Graph from tests.utils import ( generate_db_name, @@ -53,7 +52,6 @@ def test_batch_job_properties(): batch_col = batch.collection(col_name) job = batch_col.insert({'_key': '1', 'val': 1}) - assert isinstance(job.id, UUID) assert 'ArangoDB batch job {}'.format(job.id) in repr(job) @@ -139,7 +137,10 @@ def test_batch_insert_context_manager_no_commit_on_error(): except ValueError: assert len(col) == 0 assert job1.status() == 'pending' - assert job1.result() is None + # TODO CHANGED Behavior: Result of jobs without a response fails on + # error + with pytest.raises(JobResultError): + job1.result() def test_batch_insert_no_context_manager_with_result(): @@ -152,13 +153,17 @@ def test_batch_insert_no_context_manager_with_result(): assert len(col) == 0 assert job1.status() == 'pending' - assert job1.result() is None + # TODO CHANGED Behavior: Result of jobs without a response fails on error + with pytest.raises(JobResultError): + job1.result() assert job2.status() == 'pending' - assert job2.result() is None + with pytest.raises(JobResultError): + job2.result() assert job3.status() == 'pending' - assert job3.result() is None + with pytest.raises(JobResultError): + job3.result() batch.commit() assert len(col) == 2 diff --git a/tests/test_client.py b/tests/test_client.py index 61783399..c2305251 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -6,8 +6,9 @@ from six import string_types from arango import ArangoClient +from arango.connections import BaseConnection from arango.http_clients import DefaultHTTPClient -from arango.database import Database +from arango.api.databases.base import BaseDatabase from arango.exceptions import ( DatabaseCreateError, DatabaseDeleteError, @@ -41,6 +42,11 @@ def teardown_module(*_): arango_client.delete_database(db_name, ignore_missing=True) +def test_default_connection(): + conn = BaseConnection() + assert isinstance(conn.http_client, DefaultHTTPClient) + + def test_verify(): assert arango_client.verify() is True with pytest.raises(ServerConnectionError): @@ -245,11 +251,11 @@ def test_database_management(): # Test create database result = arango_client.create_database(db_name) - assert isinstance(result, Database) + assert isinstance(result, BaseDatabase) assert db_name in arango_client.databases() # Test get after create database - assert isinstance(arango_client.db(db_name), Database) + assert isinstance(arango_client.db(db_name), BaseDatabase) assert arango_client.db(db_name).name == db_name # Test create duplicate database diff --git a/tests/test_cluster.py b/tests/test_cluster.py index a945d802..d40287e6 100644 --- a/tests/test_cluster.py +++ b/tests/test_cluster.py @@ -3,10 +3,10 @@ import pytest from arango import ArangoClient -from arango.aql import AQL -from arango.collections.standard import Collection -from arango.exceptions import ClusterTestError -from arango.graph import Graph +from arango.api.wrappers.aql import AQL +from arango.api.collections import Collection +from arango.api.wrappers import Graph +from arango.exceptions import ArangoError from tests.utils import ( generate_db_name, @@ -52,5 +52,5 @@ def test_cluster_execute(): timeout=2000, sync=True ) - with pytest.raises(ClusterTestError): + with pytest.raises(ArangoError): cluster.collection(col_name).checksum() diff --git a/tests/test_collection.py b/tests/test_collection.py index bd26bc30..2904a69e 100644 --- a/tests/test_collection.py +++ b/tests/test_collection.py @@ -4,7 +4,7 @@ from six import string_types from arango import ArangoClient -from arango.collections.standard import Collection +from arango.api.collections import Collection from arango.exceptions import ( CollectionBadStatusError, CollectionChecksumError, @@ -184,3 +184,29 @@ def test_truncate(): # Test truncate missing collection with pytest.raises(CollectionTruncateError): bad_col.truncate() + + +def test_get(): + col.insert_many( + [{'_key': '5', 'val': 300, 'text': 'foo', 'coordinates': [5, 5]}]) + + # Test get with correct revision + good_rev = col.get('5')['_rev'] + result = col.get('5', rev=good_rev) + assert result['_key'] == '5' + assert result['val'] == 300 + + # Test get with "If-None-Match" and bad revision + bad_rev = col.get('5')['_rev'] + '000' + result = col.get('5', rev=bad_rev, match_rev=False) + assert result['_key'] == '5' + assert result['val'] == 300 + + +def test_has(): + col.insert_many( + [{'_key': '5', 'val': 300, 'text': 'foo', 'coordinates': [5, 5]}]) + + # Test has with "If-None-Match" and bad revision + bad_rev = col.get('5')['_rev'] + '000' + assert col.has('5', rev=bad_rev, match_rev=False) diff --git a/tests/test_database.py b/tests/test_database.py index 3ae50f4a..7e27d1d1 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -6,8 +6,9 @@ from six import string_types from arango import ArangoClient -from arango.collections.standard import Collection -from arango.graph import Graph +from arango.api.collections import Collection +from arango.api.databases import SystemDatabase +from arango.api.wrappers.graph import Graph from arango.exceptions import ( CollectionCreateError, CollectionDeleteError, @@ -434,3 +435,8 @@ def test_set_log_levels(): with pytest.raises(ServerLogLevelSetError): bad_db.set_log_levels(**new_levels) + + +def test_bad_system_db_connection(): + with pytest.raises(ValueError): + SystemDatabase(db.connection) diff --git a/tests/test_document.py b/tests/test_document.py index 8e2a815b..0f781728 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -1103,6 +1103,12 @@ def test_get_from_db(): assert result['_key'] == '5' assert result['val'] == 300 + # Test get with "If-None-Match" and bad revision + bad_rev = db.get_document(col_name + '/5')['_rev'] + '000' + result = db.get_document(col_name + '/5', rev=bad_rev, match_rev=False) + assert result['_key'] == '5' + assert result['val'] == 300 + # Test get with invalid revision bad_rev = db.get_document(col_name + '/5')['_rev'] + '000' with pytest.raises(ArangoError): diff --git a/tests/test_graph.py b/tests/test_graph.py index 3ad041bc..a0e6ece8 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -4,8 +4,8 @@ from six import string_types from arango import ArangoClient -from arango.collections.edge import EdgeCollection -from arango.collections.vertex import VertexCollection +from arango.api.collections.edge import EdgeCollection +from arango.api.collections.vertex import VertexCollection from arango.exceptions import ( ArangoError, DocumentDeleteError, diff --git a/tests/test_new_client_document.py b/tests/test_new_client_document.py new file mode 100644 index 00000000..fa77b4ec --- /dev/null +++ b/tests/test_new_client_document.py @@ -0,0 +1,1645 @@ +from __future__ import absolute_import, unicode_literals + +import pytest +from six import string_types + +from arango import ArangoClient +from arango.exceptions import ( + ArangoError, + AsyncExecuteError, + BatchExecuteError, + DocumentCountError, + DocumentDeleteError, + DocumentGetError, + DocumentInError, + DocumentInsertError, + DocumentReplaceError, + DocumentRevisionError, + DocumentUpdateError +) + +from tests.utils import ( + generate_db_name, + generate_col_name, + clean_keys, + ordered +) + +arango_client = ArangoClient(old_behavior=False) +db_name = generate_db_name() +db = arango_client.create_database(db_name).result() +col_name = generate_col_name() +col = db.create_collection(col_name).result() +edge_col_name = generate_col_name() +edge_col = db.create_collection(edge_col_name, edge=True).result() +geo_index = col.add_geo_index(['coordinates']).result() +bad_db = arango_client.database(db_name, password='invalid') +bad_col_name = generate_col_name() +bad_col = db.collection(bad_col_name) + +doc1 = {'_key': '1', 'val': 100, 'text': 'foo', 'coordinates': [1, 1]} +doc2 = {'_key': '2', 'val': 100, 'text': 'bar', 'coordinates': [2, 2]} +doc3 = {'_key': '3', 'val': 100, 'text': 'baz', 'coordinates': [3, 3]} +doc4 = {'_key': '4', 'val': 200, 'text': 'foo', 'coordinates': [4, 4]} +doc5 = {'_key': '5', 'val': 300, 'text': 'foo', 'coordinates': [5, 5]} +test_docs = [doc1, doc2, doc3, doc4, doc5] +test_doc_keys = [d['_key'] for d in test_docs] + +edge1 = {'_key': '1', '_to': '1', '_from': '2'} +edge2 = {'_key': '2', '_to': '2', '_from': '3'} +edge3 = {'_key': '3', '_to': '3', '_from': '4'} +edge4 = {'_key': '4', '_to': '4', '_from': '5'} +edge5 = {'_key': '5', '_to': '5', '_from': '1'} +test_edges = [edge1, edge2, edge3, edge4, edge5] + + +def teardown_module(*_): + arango_client.delete_database(db_name, ignore_missing=True) + + +def setup_function(*_): + col.truncate() + + +def test_insert(): + # Test insert with default options + for doc in test_docs: + result = col.insert(doc).result() + assert result['_id'] == '{}/{}'.format(col.name, doc['_key']) + assert result['_key'] == doc['_key'] + assert isinstance(result['_rev'], string_types) + assert col[doc['_key']]['val'] == doc['val'] + assert len(col) == 5 + col.truncate() + + # Test insert with sync + doc = doc1 + result = col.insert(doc, sync=True).result() + assert result['_id'] == '{}/{}'.format(col.name, doc['_key']) + assert result['_key'] == doc['_key'] + assert isinstance(result['_rev'], string_types) + assert result['sync'] is True + assert col[doc['_key']]['_key'] == doc['_key'] + assert col[doc['_key']]['val'] == doc['val'] + + # Test insert without sync + doc = doc2 + result = col.insert(doc, sync=False).result() + assert result['_id'] == '{}/{}'.format(col.name, doc['_key']) + assert result['_key'] == doc['_key'] + assert isinstance(result['_rev'], string_types) + assert result['sync'] is False + assert col[doc['_key']]['_key'] == doc['_key'] + assert col[doc['_key']]['val'] == doc['val'] + + # Test insert with return_new + doc = doc3 + result = col.insert(doc, return_new=True).result() + assert result['_id'] == '{}/{}'.format(col.name, doc['_key']) + assert result['_key'] == doc['_key'] + assert isinstance(result['_rev'], string_types) + assert result['new']['_id'] == result['_id'] + assert result['new']['_key'] == result['_key'] + assert result['new']['_rev'] == result['_rev'] + assert result['new']['val'] == doc['val'] + assert col[doc['_key']]['_key'] == doc['_key'] + assert col[doc['_key']]['val'] == doc['val'] + + # Test insert without return_new + doc = doc4 + result = col.insert(doc, return_new=False).result() + assert result['_id'] == '{}/{}'.format(col.name, doc['_key']) + assert result['_key'] == doc['_key'] + assert isinstance(result['_rev'], string_types) + assert 'new' not in result + assert col[doc['_key']]['_key'] == doc['_key'] + assert col[doc['_key']]['val'] == doc['val'] + + # Test insert duplicate document + assert isinstance(col.insert(doc4).result(), DocumentInsertError) + + +def test_insert_many(): + # Test insert_many with default options + results = col.insert_many(test_docs).result() + for result, doc in zip(results, test_docs): + assert result['_id'] == '{}/{}'.format(col.name, doc['_key']) + assert result['_key'] == doc['_key'] + assert isinstance(result['_rev'], string_types) + assert col[doc['_key']]['val'] == doc['val'] + assert len(col) == 5 + col.truncate() + + # Test insert_many with sync + results = col.insert_many(test_docs, sync=True).result() + for result, doc in zip(results, test_docs): + assert result['_id'] == '{}/{}'.format(col.name, doc['_key']) + assert result['_key'] == doc['_key'] + assert isinstance(result['_rev'], string_types) + assert result['sync'] is True + assert col[doc['_key']]['_key'] == doc['_key'] + assert col[doc['_key']]['val'] == doc['val'] + col.truncate() + + # Test insert_many without sync + results = col.insert_many(test_docs, sync=False).result() + for result, doc in zip(results, test_docs): + assert result['_id'] == '{}/{}'.format(col.name, doc['_key']) + assert result['_key'] == doc['_key'] + assert isinstance(result['_rev'], string_types) + assert result['sync'] is False + assert col[doc['_key']]['_key'] == doc['_key'] + assert col[doc['_key']]['val'] == doc['val'] + col.truncate() + + # Test insert_many with return_new + results = col.insert_many(test_docs, return_new=True).result() + for result, doc in zip(results, test_docs): + assert result['_id'] == '{}/{}'.format(col.name, doc['_key']) + assert result['_key'] == doc['_key'] + assert isinstance(result['_rev'], string_types) + assert result['new']['_id'] == result['_id'] + assert result['new']['_key'] == result['_key'] + assert result['new']['_rev'] == result['_rev'] + assert result['new']['val'] == doc['val'] + assert col[doc['_key']]['_key'] == doc['_key'] + assert col[doc['_key']]['val'] == doc['val'] + col.truncate() + + # Test insert_many without return_new + results = col.insert_many(test_docs, return_new=False).result() + for result, doc in zip(results, test_docs): + assert result['_id'] == '{}/{}'.format(col.name, doc['_key']) + assert result['_key'] == doc['_key'] + assert isinstance(result['_rev'], string_types) + assert 'new' not in result + assert col[doc['_key']]['_key'] == doc['_key'] + assert col[doc['_key']]['val'] == doc['val'] + + # Test insert_many duplicate documents + results = col.insert_many(test_docs, return_new=False).result() + for result, doc in zip(results, test_docs): + isinstance(result, DocumentInsertError) + + # Test get with missing collection + assert isinstance(bad_col.insert_many(test_docs).result(), + DocumentInsertError) + + +def test_update(): + doc = doc1.copy() + col.insert(doc) + + # Test update with default options + doc['val'] = {'foo': 1} + doc = col.update(doc).result() + assert doc['_id'] == '{}/1'.format(col.name) + assert doc['_key'] == '1' + assert isinstance(doc['_rev'], string_types) + assert col['1']['val'] == {'foo': 1} + current_rev = doc['_rev'] + + # Test update with merge + doc['val'] = {'bar': 2} + doc = col.update(doc, merge=True).result() + assert doc['_id'] == '{}/1'.format(col.name) + assert doc['_key'] == '1' + assert isinstance(doc['_rev'], string_types) + assert doc['_old_rev'] == current_rev + assert col['1']['val'] == {'foo': 1, 'bar': 2} + current_rev = doc['_rev'] + + # Test update without merge + doc['val'] = {'baz': 3} + doc = col.update(doc, merge=False).result() + assert doc['_id'] == '{}/1'.format(col.name) + assert doc['_key'] == '1' + assert isinstance(doc['_rev'], string_types) + assert doc['_old_rev'] == current_rev + assert col['1']['val'] == {'baz': 3} + current_rev = doc['_rev'] + + # Test update with keep_none + doc['val'] = None + doc = col.update(doc, keep_none=True).result() + assert doc['_id'] == '{}/1'.format(col.name) + assert doc['_key'] == '1' + assert isinstance(doc['_rev'], string_types) + assert doc['_old_rev'] == current_rev + assert col['1']['val'] is None + current_rev = doc['_rev'] + + # Test update without keep_none + doc['val'] = None + doc = col.update(doc, keep_none=False).result() + assert doc['_id'] == '{}/1'.format(col.name) + assert doc['_key'] == '1' + assert isinstance(doc['_rev'], string_types) + assert doc['_old_rev'] == current_rev + assert 'val' not in col['1'] + current_rev = doc['_rev'] + + # Test update with return_new and return_old + doc['val'] = 300 + doc = col.update(doc, return_new=True, return_old=True).result() + assert doc['_id'] == '{}/1'.format(col.name) + assert doc['_key'] == '1' + assert isinstance(doc['_rev'], string_types) + assert doc['_old_rev'] == current_rev + assert doc['new']['_key'] == '1' + assert doc['new']['val'] == 300 + assert doc['old']['_key'] == '1' + assert 'val' not in doc['old'] + assert col['1']['val'] == 300 + current_rev = doc['_rev'] + + # Test update without return_new and return_old + doc['val'] = 400 + doc = col.update(doc, return_new=False, return_old=False).result() + assert doc['_id'] == '{}/1'.format(col.name) + assert doc['_key'] == '1' + assert isinstance(doc['_rev'], string_types) + assert doc['_old_rev'] == current_rev + assert 'new' not in doc + assert 'old' not in doc + assert col['1']['val'] == 400 + current_rev = doc['_rev'] + + # Test update with check_rev + doc['val'] = 500 + doc['_rev'] = current_rev + '000' + assert isinstance(col.update(doc, check_rev=True).result(), + DocumentRevisionError) + assert col['1']['val'] == 400 + + # Test update with sync + doc['val'] = 600 + doc = col.update(doc, sync=True).result() + assert doc['_id'] == '{}/1'.format(col.name) + assert doc['_key'] == '1' + assert isinstance(doc['_rev'], string_types) + assert doc['_old_rev'] == current_rev + assert doc['sync'] is True + assert col['1']['val'] == 600 + current_rev = doc['_rev'] + + # Test update without sync + doc['val'] = 700 + doc = col.update(doc, sync=False).result() + assert doc['_id'] == '{}/1'.format(col.name) + assert doc['_key'] == '1' + assert isinstance(doc['_rev'], string_types) + assert doc['_old_rev'] == current_rev + assert doc['sync'] is False + assert col['1']['val'] == 700 + current_rev = doc['_rev'] + + # Test update missing document + assert isinstance(col.update(doc2).result(), DocumentUpdateError) + assert '2' not in col + assert col['1']['val'] == 700 + assert col['1']['_rev'] == current_rev + + # Test update in missing collection + assert isinstance(bad_col.update(doc).result(), DocumentUpdateError) + + +def test_update_many(): + current_revs = {} + docs = [doc.copy() for doc in test_docs] + doc_keys = [doc['_key'] for doc in docs] + col.insert_many(docs) + + # Test update_many with default options + for doc in docs: + doc['val'] = {'foo': 1} + results = col.update_many(docs).result() + for result, key in zip(results, doc_keys): + assert result['_id'] == '{}/{}'.format(col.name, key) + assert result['_key'] == key + assert isinstance(result['_rev'], string_types) + assert col[key]['val'] == {'foo': 1} + current_revs[key] = result['_rev'] + + # Test update_many with merge + for doc in docs: + doc['val'] = {'bar': 2} + results = col.update_many(docs, merge=True).result() + for result, doc in zip(results, docs): + key = doc['_key'] + assert result['_id'] == '{}/{}'.format(col.name, key) + assert result['_key'] == key + assert isinstance(result['_rev'], string_types) + assert result['_old_rev'] == current_revs[key] + assert col[key]['val'] == {'foo': 1, 'bar': 2} + current_revs[key] = result['_rev'] + + # Test update_many without merge + for doc in docs: + doc['val'] = {'baz': 3} + results = col.update_many(docs, merge=False).result() + for result, doc in zip(results, docs): + key = doc['_key'] + assert result['_id'] == '{}/{}'.format(col.name, key) + assert result['_key'] == key + assert isinstance(result['_rev'], string_types) + assert result['_old_rev'] == current_revs[key] + assert col[key]['val'] == {'baz': 3} + current_revs[key] = result['_rev'] + + # Test update_many with keep_none + for doc in docs: + doc['val'] = None + results = col.update_many(docs, keep_none=True).result() + for result, doc in zip(results, docs): + key = doc['_key'] + assert result['_id'] == '{}/{}'.format(col.name, key) + assert result['_key'] == key + assert isinstance(result['_rev'], string_types) + assert result['_old_rev'] == current_revs[key] + assert col[key]['val'] is None + current_revs[key] = result['_rev'] + + # Test update_many without keep_none + for doc in docs: + doc['val'] = None + results = col.update_many(docs, keep_none=False).result() + for result, doc in zip(results, docs): + key = doc['_key'] + assert result['_id'] == '{}/{}'.format(col.name, key) + assert result['_key'] == key + assert isinstance(result['_rev'], string_types) + assert result['_old_rev'] == current_revs[key] + assert 'val' not in col[key] + current_revs[key] = result['_rev'] + + # Test update_many with return_new and return_old + for doc in docs: + doc['val'] = 300 + results = col.update_many(docs, return_new=True, return_old=True).result() + for result, doc in zip(results, docs): + key = doc['_key'] + assert result['_id'] == '{}/{}'.format(col.name, key) + assert result['_key'] == key + assert isinstance(result['_rev'], string_types) + assert result['_old_rev'] == current_revs[key] + assert result['new']['_key'] == key + assert result['new']['val'] == 300 + assert result['old']['_key'] == key + assert 'val' not in result['old'] + assert col[key]['val'] == 300 + current_revs[key] = result['_rev'] + + # Test update without return_new and return_old + for doc in docs: + doc['val'] = 400 + results = col.update_many(docs, return_new=False, return_old=False)\ + .result() + for result, doc in zip(results, docs): + key = doc['_key'] + assert result['_id'] == '{}/{}'.format(col.name, key) + assert result['_key'] == key + assert isinstance(result['_rev'], string_types) + assert result['_old_rev'] == current_revs[key] + assert 'new' not in result + assert 'old' not in result + assert col[key]['val'] == 400 + current_revs[key] = result['_rev'] + + # Test update_many with check_rev + for doc in docs: + doc['val'] = 500 + doc['_rev'] = current_revs[doc['_key']] + '000' + results = col.update_many(docs, check_rev=True).result() + for result, key in zip(results, doc_keys): + assert isinstance(result, DocumentRevisionError) + for doc in col: + assert doc['val'] == 400 + + # Test update_many with sync + for doc in docs: + doc['val'] = 600 + results = col.update_many(docs, sync=True).result() + for result, doc in zip(results, docs): + key = doc['_key'] + assert result['_id'] == '{}/{}'.format(col.name, key) + assert result['_key'] == key + assert isinstance(result['_rev'], string_types) + assert result['_old_rev'] == current_revs[key] + assert result['sync'] is True + assert col[key]['val'] == 600 + current_revs[key] = result['_rev'] + + # Test update_many without sync + for doc in docs: + doc['val'] = 700 + results = col.update_many(docs, sync=False).result() + for result, doc in zip(results, docs): + key = doc['_key'] + assert result['_id'] == '{}/{}'.format(col.name, key) + assert result['_key'] == key + assert isinstance(result['_rev'], string_types) + assert result['_old_rev'] == current_revs[key] + assert result['sync'] is False + assert col[key]['val'] == 700 + current_revs[key] = result['_rev'] + + # Test update_many with missing documents + results = col.update_many([{'_key': '6'}, {'_key': '7'}]).result() + for result, key in zip(results, doc_keys): + assert isinstance(result, DocumentUpdateError) + assert '6' not in col + assert '7' not in col + for doc in col: + assert doc['val'] == 700 + + # Test update_many in missing collection + assert isinstance(bad_col.update_many(docs).result(), DocumentUpdateError) + + +def test_update_match(): + # Test preconditions + assert col.update_match({'val': 100}, {'foo': 100}).result() == 0 + + # Set up test documents + col.import_bulk(test_docs) + + # Test update single matching document + assert col.update_match({'val': 200}, {'foo': 100}).result() == 1 + assert col['4']['val'] == 200 + assert col['4']['foo'] == 100 + + # Test update multiple matching documents + assert col.update_match({'val': 100}, {'foo': 100}).result() == 3 + for key in ['1', '2', '3']: + assert col[key]['val'] == 100 + assert col[key]['foo'] == 100 + + # Test update multiple matching documents with limit + assert col.update_match( + {'val': 100}, + {'foo': 200}, + limit=2 + ).result() == 2 + assert [doc.get('foo') for doc in col].count(200) == 2 + + # Test unaffected document + assert col['5']['val'] == 300 + assert 'foo' not in col['5'] + + # Test update matching documents with sync and keep_none + assert col.update_match( + {'val': 300}, + {'val': None}, + sync=True, + keep_none=True + ).result() == 1 + assert col['5']['val'] is None + + # Test update matching documents without sync and keep_none + assert col.update_match( + {'val': 200}, + {'val': None}, + sync=False, + keep_none=False + ).result() == 1 + assert 'val' not in col['4'] + + # Test update matching documents in missing collection + assert isinstance(bad_col.update_match({'val': 100}, {'foo': 100}) + .result(), DocumentUpdateError) + + +def test_replace(): + doc = doc1.copy() + col.insert(doc) + + # Test replace with default options + doc['foo'] = 200 + doc.pop('val') + doc = col.replace(doc).result() + assert doc['_id'] == '{}/1'.format(col.name) + assert doc['_key'] == '1' + assert isinstance(doc['_rev'], string_types) + assert col['1']['foo'] == 200 + assert 'val' not in col['1'] + current_rev = doc['_rev'] + + # Test update with return_new and return_old + doc['bar'] = 300 + doc = col.replace(doc, return_new=True, return_old=True).result() + assert doc['_id'] == '{}/1'.format(col.name) + assert doc['_key'] == '1' + assert isinstance(doc['_rev'], string_types) + assert doc['_old_rev'] == current_rev + assert doc['new']['_key'] == '1' + assert doc['new']['bar'] == 300 + assert 'foo' not in doc['new'] + assert doc['old']['_key'] == '1' + assert doc['old']['foo'] == 200 + assert 'bar' not in doc['old'] + assert col['1']['bar'] == 300 + assert 'foo' not in col['1'] + current_rev = doc['_rev'] + + # Test update without return_new and return_old + doc['baz'] = 400 + doc = col.replace(doc, return_new=False, return_old=False).result() + assert doc['_id'] == '{}/1'.format(col.name) + assert doc['_key'] == '1' + assert isinstance(doc['_rev'], string_types) + assert doc['_old_rev'] == current_rev + assert 'new' not in doc + assert 'old' not in doc + assert col['1']['baz'] == 400 + assert 'bar' not in col['1'] + current_rev = doc['_rev'] + + # Test replace with check_rev + doc['foo'] = 500 + doc['_rev'] = current_rev + '000' + assert isinstance(col.replace(doc, check_rev=True).result(), + DocumentRevisionError) + assert 'foo' not in col['1'] + assert col['1']['baz'] == 400 + + # Test replace with sync + doc['foo'] = 500 + doc = col.replace(doc, sync=True).result() + assert doc['_id'] == '{}/1'.format(col.name) + assert doc['_key'] == '1' + assert isinstance(doc['_rev'], string_types) + assert doc['_old_rev'] == current_rev + assert doc['sync'] is True + assert col['1']['foo'] == 500 + assert 'baz' not in col['1'] + current_rev = doc['_rev'] + + # Test replace without sync + doc['bar'] = 600 + doc = col.replace(doc, sync=False).result() + assert doc['_id'] == '{}/1'.format(col.name) + assert doc['_key'] == '1' + assert isinstance(doc['_rev'], string_types) + assert doc['_old_rev'] == current_rev + assert doc['sync'] is False + assert col['1']['bar'] == 600 + assert 'foo' not in col['1'] + current_rev = doc['_rev'] + + # Test replace missing document + assert isinstance(col.replace(doc2).result(), DocumentReplaceError) + assert col['1']['bar'] == 600 + assert col['1']['_rev'] == current_rev + + # Test replace in missing collection + assert isinstance(bad_col.replace(doc).result(), DocumentReplaceError) + + +def test_replace_many(): + current_revs = {} + docs = [doc.copy() for doc in test_docs] + col.insert_many(docs).result() + + # Test replace_many with default options + for doc in docs: + doc['foo'] = 200 + doc.pop('val') + results = col.replace_many(docs).result() + for result, key in zip(results, test_doc_keys): + assert result['_id'] == '{}/{}'.format(col.name, key) + assert result['_key'] == key + assert isinstance(result['_rev'], string_types) + assert col[key]['foo'] == 200 + assert 'val' not in col[key] + current_revs[key] = result['_rev'] + + # Test update with return_new and return_old + for doc in docs: + doc['bar'] = 300 + doc.pop('foo') + results = col.replace_many(docs, return_new=True, return_old=True).result() + for result, doc in zip(results, docs): + key = doc['_key'] + assert result['_id'] == '{}/{}'.format(col.name, key) + assert result['_key'] == key + assert isinstance(result['_rev'], string_types) + assert result['_old_rev'] == current_revs[key] + assert result['new']['_key'] == key + assert result['new']['bar'] == 300 + assert 'foo' not in result['new'] + assert result['old']['_key'] == key + assert result['old']['foo'] == 200 + assert 'bar' not in result['old'] + assert col[key]['bar'] == 300 + current_revs[key] = result['_rev'] + + # Test update without return_new and return_old + for doc in docs: + doc['baz'] = 400 + doc.pop('bar') + results = col.replace_many(docs, return_new=False, return_old=False)\ + .result() + for result, doc in zip(results, docs): + key = doc['_key'] + assert result['_id'] == '{}/{}'.format(col.name, key) + assert result['_key'] == key + assert isinstance(result['_rev'], string_types) + assert result['_old_rev'] == current_revs[key] + assert 'new' not in result + assert 'old' not in result + assert col[key]['baz'] == 400 + assert 'bar' not in col[key] + current_revs[key] = result['_rev'] + + # Test replace_many with check_rev + for doc in docs: + doc['foo'] = 500 + doc.pop('baz') + doc['_rev'] = current_revs[doc['_key']] + '000' + results = col.replace_many(docs, check_rev=True).result() + for result, key in zip(results, test_doc_keys): + assert isinstance(result, DocumentRevisionError) + for doc in col: + assert 'foo' not in doc + assert doc['baz'] == 400 + + # Test replace_many with sync + for doc in docs: + doc['foo'] = 500 + results = col.replace_many(docs, sync=True).result() + for result, doc in zip(results, docs): + key = doc['_key'] + assert result['_id'] == '{}/{}'.format(col.name, key) + assert result['_key'] == key + assert isinstance(result['_rev'], string_types) + assert result['_old_rev'] == current_revs[key] + assert result['sync'] is True + assert col[key]['foo'] == 500 + assert 'baz' not in col[key] + current_revs[key] = result['_rev'] + + # Test replace_many without sync + for doc in docs: + doc['bar'] = 600 + doc.pop('foo') + results = col.replace_many(docs, sync=False).result() + for result, doc in zip(results, docs): + key = doc['_key'] + assert result['_id'] == '{}/{}'.format(col.name, key) + assert result['_key'] == key + assert isinstance(result['_rev'], string_types) + assert result['_old_rev'] == current_revs[key] + assert result['sync'] is False + assert col[key]['bar'] == 600 + assert 'foo' not in col[key] + current_revs[key] = result['_rev'] + + # Test replace_many with missing documents + results = col.replace_many([{'_key': '6'}, {'_key': '7'}]).result() + for result, key in zip(results, test_doc_keys): + assert isinstance(result, DocumentReplaceError) + assert '6' not in col + assert '7' not in col + for doc in col: + assert doc['bar'] == 600 + assert doc['_rev'] == current_revs[doc['_key']] + + # Test replace_many in missing collection + assert isinstance(bad_col.replace_many(docs).result(), + DocumentReplaceError) + + +def test_replace_match(): + # Test preconditions + assert col.replace_match({'val': 100}, {'foo': 100}).result() == 0 + + # Set up test documents + col.import_bulk(test_docs) + + # Test replace single matching document + assert col.replace_match({'val': 200}, {'foo': 100}).result() == 1 + assert 'val' not in col['4'] + assert col['4']['foo'] == 100 + + # Test replace multiple matching documents + assert col.replace_match({'val': 100}, {'foo': 100}).result() == 3 + for key in ['1', '2', '3']: + assert 'val' not in col[key] + assert col[key]['foo'] == 100 + + # Test replace multiple matching documents with limit + assert col.replace_match( + {'foo': 100}, + {'bar': 200}, + limit=2 + ).result() == 2 + assert [doc.get('bar') for doc in col].count(200) == 2 + + # Test unaffected document + assert col['5']['val'] == 300 + assert 'foo' not in col['5'] + + # Test replace matching documents in missing collection + assert isinstance( + bad_col.replace_match({'val': 100}, {'foo': 100}).result(), + DocumentReplaceError) + + +def test_delete(): + # Set up test documents + col.import_bulk(test_docs) + + # Test delete (document) with default options + result = col.delete(doc1).result() + assert result['_id'] == '{}/{}'.format(col.name, doc1['_key']) + assert result['_key'] == doc1['_key'] + assert isinstance(result['_rev'], string_types) + assert result['sync'] is False + assert 'old' not in result + assert doc1['_key'] not in col + assert len(col) == 4 + + # Test delete (document key) with default options + result = col.delete(doc2['_key']).result() + assert result['_id'] == '{}/{}'.format(col.name, doc2['_key']) + assert result['_key'] == doc2['_key'] + assert isinstance(result['_rev'], string_types) + assert result['sync'] is False + assert 'old' not in result + assert doc2['_key'] not in col + assert len(col) == 3 + + # Test delete (document) with return_old + result = col.delete(doc3, return_old=True).result() + assert result['_id'] == '{}/{}'.format(col.name, doc3['_key']) + assert result['_key'] == doc3['_key'] + assert isinstance(result['_rev'], string_types) + assert result['sync'] is False + assert result['old']['_key'] == doc3['_key'] + assert result['old']['val'] == 100 + assert doc3['_key'] not in col + assert len(col) == 2 + + # Test delete (document key) with sync + result = col.delete(doc4, sync=True).result() + assert result['_id'] == '{}/{}'.format(col.name, doc4['_key']) + assert result['_key'] == doc4['_key'] + assert isinstance(result['_rev'], string_types) + assert result['sync'] is True + assert doc4['_key'] not in col + assert len(col) == 1 + + # Test delete (document) with check_rev + rev = col[doc5['_key']]['_rev'] + '000' + bad_doc = doc5.copy() + bad_doc.update({'_rev': rev}) + assert isinstance(col.delete(bad_doc, check_rev=True).result(), + ArangoError) + assert bad_doc['_key'] in col + assert len(col) == 1 + + bad_doc.update({'_rev': 'bad_rev'}) + assert isinstance(col.delete(bad_doc, check_rev=True).result(), + ArangoError) + assert bad_doc['_key'] in col + assert len(col) == 1 + + # Test delete (document) with check_rev + assert col.delete(doc4, ignore_missing=True).result() is False + assert isinstance(col.delete(doc4, ignore_missing=False).result(), + ArangoError) + assert len(col) == 1 + + # Test delete with missing collection + assert isinstance(bad_col.delete(doc5).result(), ArangoError) + assert isinstance(bad_col.delete(doc5['_key']).result(), ArangoError) + + # Test delete with wrong user credentials + err = bad_db.collection(col_name).delete(doc5).result() + assert isinstance(err, DocumentDeleteError) \ + or isinstance(err, AsyncExecuteError) \ + or isinstance(err, BatchExecuteError) + + +def test_delete_many(): + # Set up test documents + current_revs = {} + docs = [doc.copy() for doc in test_docs] + + # Test delete_many (documents) with default options + col.import_bulk(docs) + results = col.delete_many(docs).result() + for result, key in zip(results, test_doc_keys): + assert result['_id'] == '{}/{}'.format(col.name, key) + assert result['_key'] == key + assert isinstance(result['_rev'], string_types) + assert result['sync'] is False + assert 'old' not in result + assert key not in col + current_revs[key] = result['_rev'] + assert len(col) == 0 + + # Test delete_many (document keys) with default options + col.import_bulk(docs) + results = col.delete_many(docs).result() + for result, key in zip(results, test_doc_keys): + assert result['_id'] == '{}/{}'.format(col.name, key) + assert result['_key'] == key + assert isinstance(result['_rev'], string_types) + assert result['sync'] is False + assert 'old' not in result + assert key not in col + current_revs[key] = result['_rev'] + assert len(col) == 0 + + # Test delete_many (documents) with return_old + col.import_bulk(docs) + results = col.delete_many(docs, return_old=True).result() + for result, doc in zip(results, docs): + key = doc['_key'] + assert result['_id'] == '{}/{}'.format(col.name, key) + assert result['_key'] == key + assert isinstance(result['_rev'], string_types) + assert result['sync'] is False + assert result['old']['_key'] == key + assert result['old']['val'] == doc['val'] + assert key not in col + current_revs[key] = result['_rev'] + assert len(col) == 0 + + # Test delete_many (document keys) with sync + col.import_bulk(docs) + results = col.delete_many(docs, sync=True).result() + for result, doc in zip(results, docs): + key = doc['_key'] + assert result['_id'] == '{}/{}'.format(col.name, key) + assert result['_key'] == key + assert isinstance(result['_rev'], string_types) + assert result['sync'] is True + assert 'old' not in result + assert key not in col + current_revs[key] = result['_rev'] + assert len(col) == 0 + + # Test delete_many (documents) with check_rev + col.import_bulk(docs) + for doc in docs: + doc['_rev'] = current_revs[doc['_key']] + '000' + results = col.delete_many(docs, check_rev=True).result() + for result, doc in zip(results, docs): + assert isinstance(result, DocumentRevisionError) + assert len(col) == 5 + + # Test delete_many (documents) with missing documents + col.truncate() + results = col.delete_many([{'_key': '6'}, {'_key': '7'}]).result() + for result, doc in zip(results, docs): + assert isinstance(result, DocumentDeleteError) + assert len(col) == 0 + + # Test delete_many with missing collection + assert isinstance(bad_col.delete_many(docs).result(), DocumentDeleteError) + assert isinstance(bad_col.delete_many(test_doc_keys).result(), + DocumentDeleteError) + + +def test_delete_match(): + # Test preconditions + assert col.delete_match({'val': 100}).result() == 0 + + # Set up test documents + col.import_bulk(test_docs) + + # Test delete matching documents with default options + assert '4' in col + assert col.delete_match({'val': 200}).result() == 1 + assert '4' not in col + + # Test delete matching documents with sync + assert '5' in col + assert col.delete_match({'val': 300}, sync=True).result() == 1 + assert '5' not in col + + # Test delete matching documents with limit of 2 + assert col.delete_match({'val': 100}, limit=2).result() == 2 + assert [doc['val'] for doc in col].count(100) == 1 + + assert isinstance(bad_col.delete_match({'val': 100}).result(), + DocumentDeleteError) + + +def test_count(): + # Set up test documents + col.import_bulk(test_docs) + + assert len(col) == len(test_docs) + assert col.count().result() == len(test_docs) + + assert isinstance(bad_col.count().result(), DocumentCountError) + + with pytest.raises(DocumentCountError): + len(bad_col) + + +def test_find(): + # Check preconditions + assert len(col) == 0 + + # Set up test documents + col.import_bulk(test_docs).result() + + # Test find (single match) with default options + found = list(col.find({'val': 200}).result()) + assert len(found) == 1 + assert found[0]['_key'] == '4' + + # Test find (multiple matches) with default options + found = list(col.find({'val': 100}).result()) + assert len(found) == 3 + for doc in map(dict, found): + assert doc['_key'] in {'1', '2', '3'} + assert doc['_key'] in col + + # Test find with offset + found = list(col.find({'val': 100}, offset=1).result()) + assert len(found) == 2 + for doc in map(dict, found): + assert doc['_key'] in {'1', '2', '3'} + assert doc['_key'] in col + + # Test find with limit + for limit in [3, 4, 5]: + found = list(col.find({}, limit=limit).result()) + assert len(found) == limit + for doc in map(dict, found): + assert doc['_key'] in {'1', '2', '3', '4', '5'} + assert doc['_key'] in col + + # Test find in empty collection + col.truncate() + assert list(col.find({}).result()) == [] + assert list(col.find({'val': 100}).result()) == [] + assert list(col.find({'val': 200}).result()) == [] + assert list(col.find({'val': 300}).result()) == [] + assert list(col.find({'val': 400}).result()) == [] + + # Test find in missing collection + assert isinstance(bad_col.find({'val': 100}).result(), DocumentGetError) + + +def test_has(): + # Set up test documents + col.import_bulk(test_docs) + + # Test has existing document + assert col.has('1').result() is True + + # Test has another existing document + assert col.has('2').result() is True + + # Test has missing document + assert col.has('6').result() is False + + # Test has with correct revision + good_rev = col['5']['_rev'] + assert col.has('5', rev=good_rev).result() is True + + # Test has with invalid revision + bad_rev = col['5']['_rev'] + '000' + assert isinstance(col.has('5', rev=bad_rev, match_rev=True).result(), + ArangoError) + + # Test has with correct revision and match_rev turned off + # bad_rev = col['5']['_rev'] + '000' + # assert col.has('5', rev=bad_rev, match_rev=False) is True + + assert isinstance(bad_col.has('1').result(), DocumentInError) + + with pytest.raises(DocumentInError): + print('1' in bad_col) + + +def test_get(): + # Set up test documents + col.import_bulk(test_docs) + + # Test get existing document + result = col.get('1').result() + assert result['_key'] == '1' + assert result['val'] == 100 + + # Test get another existing document + result = col.get('2').result() + assert result['_key'] == '2' + assert result['val'] == 100 + + # Test get missing document + assert col.get('6').result() is None + + # Test get with correct revision + good_rev = col['5']['_rev'] + result = col.get('5', rev=good_rev).result() + assert result['_key'] == '5' + assert result['val'] == 300 + + # Test get with invalid revision + bad_rev = col['5']['_rev'] + '000' + assert isinstance(col.get('5', rev=bad_rev, match_rev=True).result(), + ArangoError) + assert isinstance(col.get('5', rev='bad_rev').result(), ArangoError) + + # TODO uncomment once match_rev flag is fixed + # # Test get with correct revision and match_rev turned off + # bad_rev = col['5']['_rev'] + '000' + # result = col.get('5', rev=bad_rev, match_rev=False) + # assert result['_key'] == '5' + # assert result['_rev'] != bad_rev + # assert result['val'] == 300 + + # Test get with missing collection + assert isinstance(bad_col.get('1').result(), DocumentGetError) + + with pytest.raises(DocumentGetError): + print(bad_col['1']) + + with pytest.raises(DocumentGetError): + iter(bad_col) + + +def test_get_from_db(): + # Set up test documents + col.import_bulk(test_docs) + + # Test get existing document + result = db.get_document(col_name + '/1').result() + assert result['_key'] == '1' + assert result['val'] == 100 + + # Test get another existing document + result = db.get_document(col_name + '/2').result() + assert result['_key'] == '2' + assert result['val'] == 100 + + # Test get missing document + assert db.get_document(col_name + '/6').result() is None + + # Test get with correct revision + good_rev = db.get_document(col_name + '/5').result()['_rev'] + result = db.get_document(col_name + '/5', rev=good_rev).result() + assert result['_key'] == '5' + assert result['val'] == 300 + + # Test get with "If-None-Match" and bad revision + bad_rev = db.get_document(col_name + '/5').result()['_rev'] + '000' + result = db.get_document(col_name + '/5', rev=bad_rev, match_rev=False)\ + .result() + assert result['_key'] == '5' + assert result['val'] == 300 + + # Test get with invalid revision + bad_rev = db.get_document(col_name + '/5').result()['_rev'] + '000' + assert isinstance(db.get_document(col_name + '/5', rev=bad_rev, + match_rev=True).result(), ArangoError) + assert isinstance(db.get_document(col_name + '/5', + rev="bad_rev").result(), ArangoError) + assert isinstance(db.get_document(bad_col_name + '/1').result(), + DocumentGetError) + + +def test_get_many(): + # Test precondition + assert len(col) == 0 + + # Set up test documents + col.import_bulk(test_docs) + + # Test get_many missing documents + assert col.get_many(['6']).result() == [] + assert col.get_many(['6', '7']).result() == [] + assert col.get_many(['6', '7', '8']).result() == [] + + # Test get_many existing documents + result = col.get_many(['1']).result() + result = clean_keys(result) + assert result == [doc1] + + result = col.get_many(['2']).result() + result = clean_keys(result) + assert result == [doc2] + + result = col.get_many(['3', '4']).result() + assert clean_keys(result) == [doc3, doc4] + + result = col.get_many(['1', '3', '6']).result() + assert clean_keys(result) == [doc1, doc3] + + # Test get_many in empty collection + col.truncate() + assert col.get_many([]).result() == [] + assert col.get_many(['1']).result() == [] + assert col.get_many(['2', '3']).result() == [] + assert col.get_many(['2', '3', '4']).result() == [] + + assert isinstance(bad_col.get_many(['2', '3', '4']).result(), + DocumentGetError) + + +def test_all(): + # Check preconditions + assert len(list(col.export().result())) == 0 + + # Set up test documents + col.import_bulk(test_docs) + + # Test all with default options + result = list(col.all().result()) + assert ordered(clean_keys(result)) == test_docs + + # Test all with a skip of 0 + result = col.all(skip=0).result() + assert result.count() == len(test_docs) + assert ordered(clean_keys(result)) == test_docs + + # Test all with a skip of 1 + result = col.all(skip=1).result() + assert result.count() == 4 + assert len(list(result)) == 4 + for doc in list(clean_keys(result)): + assert doc in test_docs + + # Test all with a skip of 3 + result = col.all(skip=3).result() + assert result.count() == 2 + assert len(list(result)) == 2 + for doc in list(clean_keys(list(result))): + assert doc in test_docs + + # Test all with a limit of 0 + result = col.all(limit=0).result() + assert result.count() == 0 + assert ordered(clean_keys(result)) == [] + + # Test all with a limit of 1 + result = col.all(limit=1).result() + assert result.count() == 1 + assert len(list(result)) == 1 + for doc in list(clean_keys(result)): + assert doc in test_docs + + # Test all with a limit of 3 + result = col.all(limit=3).result() + assert result.count() == 3 + assert len(list(result)) == 3 + for doc in list(clean_keys(list(result))): + assert doc in test_docs + + # Test all with skip and limit + result = col.all(skip=4, limit=2).result() + assert result.count() == 1 + assert len(list(result)) == 1 + for doc in list(clean_keys(list(result))): + assert doc in test_docs + + # Test export in missing collection + assert isinstance(bad_col.all().result(), DocumentGetError) + +# TODO uncomment when export with flush works properly +# def test_export(): +# # Check preconditions +# assert len(list(col.export())) == 0 +# +# # Set up test documents +# col.import_bulk(test_docs) +# +# # Test export with default options +# result = list(col.export()) +# assert ordered(clean_keys(result)) == test_docs +# +# # Test export with flush +# # result = list(col.export(flush=True, flush_wait=1)) +# # assert ordered(clean_keys(result)) == test_docs +# +# # Test export with count +# result = col.export(count=True) +# assert result.count() == len(test_docs) +# assert ordered(clean_keys(result)) == test_docs +# +# # Test export with batch size +# result = col.export(count=True, batch_size=1) +# assert result.count() == len(test_docs) +# assert ordered(clean_keys(result)) == test_docs +# +# # Test export with time-to-live +# result = col.export(count=True, ttl=1000) +# assert result.count() == len(test_docs) +# assert ordered(clean_keys(result)) == test_docs +# +# # Test export with filters +# result = col.export( +# count=True, +# filter_fields=['text'], +# filter_type='exclude' +# ) +# assert result.count() == 5 +# for doc in result: +# assert 'text' not in doc +# +# # Test export with a limit of 0 +# result = col.export(count=True, limit=0) +# assert result.count() == len(test_docs) +# assert ordered(clean_keys(result)) == test_docs +# +# # Test export with a limit of 1 +# result = col.export(count=True, limit=1) +# assert result.count() == 1 +# assert len(list(result)) == 1 +# for doc in list(clean_keys(result)): +# assert doc in test_docs +# +# # Test export with a limit of 3 +# result = col.export(count=True, limit=3) +# assert result.count() == 3 +# assert len(list(result)) == 3 +# for doc in list(clean_keys(list(result))): +# assert doc in test_docs +# +# # Test export in missing collection +# with pytest.raises(DocumentGetError): +# bad_col.export() +# +# # Test closing export cursor +# result = col.export(count=True, batch_size=1) +# assert result.close(ignore_missing=False) is True +# assert result.close(ignore_missing=True) is False +# +# assert clean_keys(result.next()) == doc1 +# with pytest.raises(CursorNextError): +# result.next() +# with pytest.raises(CursorCloseError): +# result.close(ignore_missing=False) +# +# result = col.export(count=True) +# assert result.close(ignore_missing=True) is False + + +def test_random(): + # Set up test documents + col.import_bulk(test_docs) + + # Test random in non-empty collection + for attempt in range(10): + random_doc = col.random().result() + assert clean_keys(random_doc) in test_docs + + # Test random in empty collection + col.truncate() + for attempt in range(10): + random_doc = col.random().result() + assert random_doc is None + + # Test random in missing collection + assert isinstance(bad_col.random().result(), DocumentGetError) + + +def test_find_near(): + # Set up test documents + col.import_bulk(test_docs) + + # Test find_near with default options + result = col.find_near(latitude=1, longitude=1).result() + assert [doc['_key'] for doc in result] == ['1', '2', '3', '4', '5'] + + # Test find_near with limit of 0 + result = col.find_near(latitude=1, longitude=1, limit=0).result() + assert [doc['_key'] for doc in result] == [] + + # Test find_near with limit of 1 + result = col.find_near(latitude=1, longitude=1, limit=1).result() + assert [doc['_key'] for doc in result] == ['1'] + + # Test find_near with limit of 3 + result = col.find_near(latitude=1, longitude=1, limit=3).result() + assert [doc['_key'] for doc in result] == ['1', '2', '3'] + + # Test find_near with limit of 3 (another set of coordinates) + result = col.find_near(latitude=5, longitude=5, limit=3).result() + assert [doc['_key'] for doc in result] == ['5', '4', '3'] + + # Test random in missing collection + assert isinstance(bad_col.find_near(latitude=1, longitude=1, + limit=1).result(), DocumentGetError) + + # Test find_near in an empty collection + col.truncate() + result = col.find_near(latitude=1, longitude=1, limit=1).result() + assert list(result) == [] + result = col.find_near(latitude=5, longitude=5, limit=4).result() + assert list(result) == [] + + # Test find near in missing collection + assert isinstance(bad_col.find_near(latitude=1, longitude=1, + limit=1).result(), DocumentGetError) + + +def test_find_in_range(): + # Set up required index + col.add_skiplist_index(['val']) + + # Set up test documents + col.import_bulk(test_docs) + + # Test find_in_range with default options + result = col.find_in_range(field='val', lower=100, upper=200).result() + assert [doc['_key'] for doc in result] == ['1', '2', '3', '4'] + + # Test find_in_range with limit of 0 + result = col.find_in_range(field='val', lower=100, upper=200, limit=0)\ + .result() + assert [doc['_key'] for doc in result] == [] + + # Test find_in_range with limit of 3 + result = col.find_in_range(field='val', lower=100, upper=200, limit=3)\ + .result() + assert [doc['_key'] for doc in result] == ['1', '2', '3'] + + # Test find_in_range with offset of 0 + result = col.find_in_range(field='val', lower=100, upper=200, offset=0)\ + .result() + assert [doc['_key'] for doc in result] == ['1', '2', '3', '4'] + + # Test find_in_range with offset of 2 + result = col.find_in_range(field='val', lower=100, upper=200, offset=2)\ + .result() + assert [doc['_key'] for doc in result] == ['3', '4'] + + # Test find_in_range without inclusive + result = col.find_in_range('val', 100, 200, inclusive=False).result() + assert [doc['_key'] for doc in result] == [] + + # Test find_in_range without inclusive + result = col.find_in_range('val', 100, 300, inclusive=False).result() + assert [doc['_key'] for doc in result] == ['4'] + + # Test find_in_range in missing collection + assert isinstance(bad_col.find_in_range(field='val', lower=100, + upper=200, offset=2).result(), + DocumentGetError) + + +# TODO the WITHIN geo function does not seem to work properly +def test_find_in_radius(): + col.import_bulk([ + {'_key': '1', 'coordinates': [1, 1]}, + {'_key': '2', 'coordinates': [1, 4]}, + {'_key': '3', 'coordinates': [4, 1]}, + {'_key': '4', 'coordinates': [4, 4]}, + ]) + result = list(col.find_in_radius(3, 3, 10, 'distance').result()) + for doc in result: + assert 'distance' in doc + + # Test find_in_radius in missing collection + assert isinstance(bad_col.find_in_radius(3, 3, 10, 'distance').result(), + DocumentGetError) + + +def test_find_in_box(): + # Set up test documents + d1 = {'_key': '1', 'coordinates': [1, 1]} + d2 = {'_key': '2', 'coordinates': [1, 5]} + d3 = {'_key': '3', 'coordinates': [5, 1]} + d4 = {'_key': '4', 'coordinates': [5, 5]} + col.import_bulk([d1, d2, d3, d4]) + + # Test find_in_box with default options + result = col.find_in_box( + latitude1=0, + longitude1=0, + latitude2=6, + longitude2=3, + geo_field=geo_index['id'] + ).result() + assert clean_keys(result) == [d3, d1] + + # Test find_in_box with limit of 0 + result = col.find_in_box( + latitude1=0, + longitude1=0, + latitude2=6, + longitude2=3, + limit=0, + geo_field=geo_index['id'] + ).result() + assert clean_keys(result) == [d3, d1] + + # Test find_in_box with limit of 1 + result = col.find_in_box( + latitude1=0, + longitude1=0, + latitude2=6, + longitude2=3, + limit=1, + ).result() + assert clean_keys(result) == [d3] + + # Test find_in_box with limit of 4 + result = col.find_in_box( + latitude1=0, + longitude1=0, + latitude2=10, + longitude2=10, + limit=4 + ).result() + assert clean_keys(result) == [d4, d3, d2, d1] + + # Test find_in_box with skip 1 + result = col.find_in_box( + latitude1=0, + longitude1=0, + latitude2=6, + longitude2=3, + skip=1, + ).result() + assert clean_keys(result) == [d1] + + # Test find_in_box with skip 3 + result = col.find_in_box( + latitude1=0, + longitude1=0, + latitude2=10, + longitude2=10, + skip=2 + ).result() + assert clean_keys(result) == [d2, d1] + + # Test find_in_box in missing collection + assert isinstance(bad_col.find_in_box( + latitude1=0, + longitude1=0, + latitude2=6, + longitude2=3, + ).result(), + DocumentGetError) + + +def test_find_by_text(): + # Set up required index + col.add_fulltext_index(['text']) + + # Set up test documents + col.import_bulk(test_docs) + + # Test find_by_text with default options + result = col.find_by_text(key='text', query='bar,|baz').result() + assert clean_keys(list(result)) == [doc2, doc3] + + # Test find_by_text with limit + result = col.find_by_text(key='text', query='foo', limit=1).result() + assert len(list(result)) == 1 + result = col.find_by_text(key='text', query='foo', limit=2).result() + assert len(list(result)) == 2 + result = col.find_by_text(key='text', query='foo', limit=3).result() + assert len(list(result)) == 3 + + # Test find_by_text with invalid queries + assert isinstance(col.find_by_text(key='text', query='+').result(), + DocumentGetError) + assert isinstance(col.find_by_text(key='text', query='|').result(), + DocumentGetError) + + # Test find_by_text with missing column + assert isinstance(col.find_by_text(key='missing', query='foo').result(), + DocumentGetError) + + +def test_import_bulk(): + # Test import_bulk with default options + result = col.import_bulk(test_docs).result() + assert result['created'] == 5 + assert result['errors'] == 0 + assert result['empty'] == 0 + assert result['updated'] == 0 + assert result['ignored'] == 0 + assert 'details' in result + assert len(col) == 5 + for doc in test_docs: + key = doc['_key'] + assert key in col + assert col[key]['_key'] == key + assert col[key]['val'] == doc['val'] + assert col[key]['coordinates'] == doc['coordinates'] + col.truncate() + + # Test import bulk without details and with sync + result = col.import_bulk(test_docs, details=False, sync=True).result() + assert result['created'] == 5 + assert result['errors'] == 0 + assert result['empty'] == 0 + assert result['updated'] == 0 + assert result['ignored'] == 0 + assert 'details' not in result + assert len(col) == 5 + for doc in test_docs: + key = doc['_key'] + assert key in col + assert col[key]['_key'] == key + assert col[key]['val'] == doc['val'] + assert col[key]['coordinates'] == doc['coordinates'] + col.truncate() + + # Test import_bulk duplicates with halt_on_error + assert isinstance(col.import_bulk([doc1, doc1], halt_on_error=True) + .result(), DocumentInsertError) + assert len(col) == 0 + + # Test import bulk duplicates without halt_on_error + result = col.import_bulk([doc2, doc2], halt_on_error=False).result() + assert result['created'] == 1 + assert result['errors'] == 1 + assert result['empty'] == 0 + assert result['updated'] == 0 + assert result['ignored'] == 0 + assert len(col) == 1 + + # Test import bulk in missing collection + assert isinstance(bad_col.import_bulk([doc3, doc4], halt_on_error=True) + .result(), + DocumentInsertError) + assert len(col) == 1 + + # Test import bulk with overwrite + result = col.import_bulk([doc3, doc4], overwrite=True).result() + assert result['created'] == 2 + assert result['errors'] == 0 + assert result['empty'] == 0 + assert result['updated'] == 0 + assert result['ignored'] == 0 + assert '1' not in col + assert '2' not in col + assert '3' in col + assert '4' in col + col.truncate() + + # Test import bulk to_prefix and from_prefix + result = edge_col.import_bulk( + test_edges, from_prefix='foo', to_prefix='bar' + ).result() + assert result['created'] == 5 + assert result['errors'] == 0 + assert result['empty'] == 0 + assert result['updated'] == 0 + assert result['ignored'] == 0 + for edge in test_edges: + key = edge['_key'] + assert key in edge_col + assert edge_col[key]['_from'] == 'foo/' + edge['_from'] + assert edge_col[key]['_to'] == 'bar/' + edge['_to'] + edge_col.truncate() + + # Test import bulk on_duplicate actions + old_doc = {'_key': '1', 'foo': '2'} + new_doc = {'_key': '1', 'bar': '3'} + + col.insert(old_doc) + result = col.import_bulk([new_doc], on_duplicate='error').result() + assert len(col) == 1 + assert result['created'] == 0 + assert result['errors'] == 1 + assert result['empty'] == 0 + assert result['updated'] == 0 + assert result['ignored'] == 0 + assert col['1']['foo'] == '2' + assert 'bar' not in col['1'] + + result = col.import_bulk([new_doc], on_duplicate='ignore').result() + assert len(col) == 1 + assert result['created'] == 0 + assert result['errors'] == 0 + assert result['empty'] == 0 + assert result['updated'] == 0 + assert result['ignored'] == 1 + assert col['1']['foo'] == '2' + assert 'bar' not in col['1'] + + result = col.import_bulk([new_doc], on_duplicate='update').result() + assert len(col) == 1 + assert result['created'] == 0 + assert result['errors'] == 0 + assert result['empty'] == 0 + assert result['updated'] == 1 + assert result['ignored'] == 0 + assert col['1']['foo'] == '2' + assert col['1']['bar'] == '3' + + col.truncate() + col.insert(old_doc) + result = col.import_bulk([new_doc], on_duplicate='replace').result() + assert len(col) == 1 + assert result['created'] == 0 + assert result['errors'] == 0 + assert result['empty'] == 0 + assert result['updated'] == 1 + assert result['ignored'] == 0 + assert 'foo' not in col['1'] + assert col['1']['bar'] == '3' diff --git a/tests/test_pregel.py b/tests/test_pregel.py index 75146da1..8e3c44e9 100644 --- a/tests/test_pregel.py +++ b/tests/test_pregel.py @@ -65,7 +65,8 @@ def test_get_pregel_job(): assert isinstance(job['received_count'], int) assert isinstance(job['send_count'], int) assert isinstance(job['total_runtime'], float) - assert job['state'] == 'running' + # TODO CHANGED to prevent race condition + assert job['state'] in {'running', 'done'} # Test pregel_job with an invalid job ID with pytest.raises(PregelJobGetError): diff --git a/tests/test_transaction.py b/tests/test_transaction.py index 9281204a..f6a82c27 100644 --- a/tests/test_transaction.py +++ b/tests/test_transaction.py @@ -3,7 +3,7 @@ import pytest from arango import ArangoClient -from arango.collections.standard import Collection +from arango.api.collections import Collection from arango.exceptions import TransactionError from tests.utils import ( @@ -424,3 +424,14 @@ def test_bad_collections(): ) as txn: txn_col = txn.collection(col_name) txn_col.insert(doc2) + + +def test_transaction_result(): + txn = db.transaction(write=col_name) + + txn_col = txn.collection(col_name) + txn_col.insert_many(test_docs) + + x = txn.commit() + + assert len(x[0]) == len(test_docs) diff --git a/tests/test_user.py b/tests/test_user.py index 3836a02d..3f4c64e8 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -235,11 +235,13 @@ def test_get_user_access(): arango_client.create_user(username=username, password='password') # Get user access (should be empty initially) - assert arango_client.user_access(username) == [] + # TODO CHANGED due to change in underlying behavior + assert arango_client.user_access(username) == {} # Grant user access to the database and check again arango_client.grant_user_access(username, db_name) - assert arango_client.user_access(username) == [db_name] + # TODO CHANGED due to change in underlying behavior + assert arango_client.user_access(username)[db_name] == 'rw' # Get access of a missing user bad_username = generate_user_name() @@ -515,15 +517,20 @@ def test_get_user_access_db_level(): db.create_user(username=username, password='password') # Get user access (should be none initially) - assert db.user_access(username) is None + # TODO CHANGED due to change in underlying behavior + assert len(db.user_access(username)) == 0 # Grant user access to the database and check again + # TODO CHANGED due to change in underlying behavior db.grant_user_access(username) - assert db.user_access(username) == 'rw' + assert db.user_access(username)[db_name] == 'rw' # Get access of a missing user + # TODO CHANGED due to change in underlying behavior bad_username = generate_user_name() - assert db.user_access(bad_username) is None + with pytest.raises(UserAccessError) as err: + db.user_access(bad_username) + assert err.value.http_code == 404 # Get user access from a bad database (incorrect password) with pytest.raises(UserAccessError) as err: diff --git a/tests/test_version.py b/tests/test_version.py index 16ab289a..a1996d6a 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1,4 +1,4 @@ -from arango.version import VERSION +from arango.utils import VERSION def test_package_version(): diff --git a/tests/utils.py b/tests/utils.py index 230d195e..f208707e 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -7,7 +7,7 @@ def arango_version(client): """Return the major and minor version of ArangoDB. :param client: The ArangoDB client. - :type client: arango.ArangoClient + :type client: arango.SystemDatabase :return: The major and minor version numbers. :rtype: (int, int) """