├── .circleci
└── config.yml
├── .github
└── workflows
│ ├── codeql.yaml
│ ├── docs.yaml
│ └── pypi.yaml
├── .gitignore
├── .pre-commit-config.yaml
├── .readthedocs.yaml
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── arangoasync
├── __init__.py
├── aql.py
├── auth.py
├── client.py
├── collection.py
├── compression.py
├── connection.py
├── cursor.py
├── database.py
├── errno.py
├── exceptions.py
├── executor.py
├── graph.py
├── http.py
├── job.py
├── logger.py
├── request.py
├── resolver.py
├── response.py
├── result.py
├── serialization.py
├── typings.py
└── version.py
├── docs
├── aql.rst
├── async.rst
├── authentication.rst
├── certificates.rst
├── collection.rst
├── compression.rst
├── conf.py
├── cursor.rst
├── database.rst
├── document.rst
├── errno.rst
├── errors.rst
├── graph.rst
├── helpers.rst
├── http.rst
├── index.rst
├── indexes.rst
├── logging.rst
├── migration.rst
├── overview.rst
├── serialization.rst
├── specs.rst
├── static
│ └── logo.png
├── transaction.rst
└── user.rst
├── pyproject.toml
├── setup.cfg
├── setup.py
├── starter.sh
└── tests
├── __init__.py
├── conftest.py
├── helpers.py
├── static
├── cluster-3.11.conf
├── cluster-3.12.conf
├── keyfile
├── single-3.11.conf
└── single-3.12.conf
├── test_aql.py
├── test_async.py
├── test_client.py
├── test_collection.py
├── test_compression.py
├── test_connection.py
├── test_cursor.py
├── test_database.py
├── test_document.py
├── test_graph.py
├── test_http.py
├── test_resolver.py
├── test_transaction.py
├── test_typings.py
└── test_user.py
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2.1
2 |
3 | executors:
4 | python-container:
5 | docker:
6 | - image: cimg/python:3.12
7 | resource_class: small
8 | python-vm:
9 | machine:
10 | image: ubuntu-2204:current
11 | resource_class: medium
12 |
13 | workflows:
14 | ci:
15 | jobs:
16 | - lint
17 | - test:
18 | name: Python (<< matrix.python_version >>) - ArangoDB (<< matrix.arangodb_license >>, << matrix.arangodb_version >> << matrix.arangodb_config >>)
19 | matrix:
20 | parameters:
21 | python_version: ["3.10", "3.11", "3.12"]
22 | arangodb_config: ["single", "cluster"]
23 | arangodb_license: ["community", "enterprise"]
24 | arangodb_version: ["3.11", "3.12"]
25 |
26 | jobs:
27 | lint:
28 | executor: python-container
29 | resource_class: small
30 | steps:
31 | - checkout
32 | - run:
33 | name: Install Dependencies
34 | command: pip install .[dev]
35 | - run:
36 | name: Run black
37 | command: black --check --verbose --diff --color --config=pyproject.toml ./arangoasync ./tests/
38 | - run:
39 | name: Run flake8
40 | command: flake8 ./arangoasync ./tests
41 | - run:
42 | name: Run isort
43 | command: isort --check ./arangoasync ./tests
44 | - run:
45 | name: Run mypy
46 | command: mypy ./arangoasync
47 | test:
48 | parameters:
49 | python_version:
50 | type: string
51 | arangodb_config:
52 | type: string
53 | arangodb_license:
54 | type: string
55 | arangodb_version:
56 | type: string
57 | executor: python-vm
58 | steps:
59 | - checkout
60 | - run:
61 | name: Setup ArangoDB
62 | command: |
63 | chmod +x starter.sh
64 | ./starter.sh << parameters.arangodb_config >> << parameters.arangodb_license >> << parameters.arangodb_version >>
65 | - restore_cache:
66 | key: pip-and-local-cache
67 | - run:
68 | name: Setup Python
69 | command: |
70 | pyenv --version
71 | pyenv install -f << parameters.python_version >>
72 | pyenv global << parameters.python_version >>
73 | - run:
74 | name: Install Dependencies
75 | command: pip install -e .[dev]
76 | - run: docker ps -a
77 | - run: docker logs arango
78 | - run:
79 | name: Run pytest
80 | command: |
81 | mkdir test-results
82 | mkdir htmlcov
83 |
84 | args=("--junitxml=test-results/junit.xml" "--log-cli-level=DEBUG" "--host" "localhost" "--port=8529")
85 | if [ << parameters.arangodb_config >> = "cluster" ]; then
86 | args+=("--cluster" "--port=8539" "--port=8549")
87 | fi
88 |
89 | if [ << parameters.arangodb_license >> = "enterprise" ]; then
90 | args+=("--enterprise")
91 | fi
92 |
93 | echo "Running pytest with args: ${args[@]}"
94 | pytest --cov=arangoasync --cov-report=html:htmlcov --color=yes --code-highlight=yes "${args[@]}"
95 | - store_artifacts:
96 | path: htmlcov
97 | destination: coverage-report
98 | - store_artifacts:
99 | path: test-results
100 | - store_test_results:
101 | path: test-results
102 |
--------------------------------------------------------------------------------
/.github/workflows/codeql.yaml:
--------------------------------------------------------------------------------
1 | name: CodeQL
2 |
3 | on:
4 | pull_request:
5 | branches: [main]
6 |
7 | jobs:
8 | analyze:
9 | name: Analyze
10 | runs-on: ubuntu-latest
11 |
12 | permissions:
13 | security-events: write
14 |
15 | steps:
16 | - name: Checkout repository
17 | uses: actions/checkout@v3
18 |
19 | - name: Initialize CodeQL
20 | uses: github/codeql-action/init@v2
21 |
22 | - name: Perform CodeQL Analysis
23 | uses: github/codeql-action/analyze@v2
24 |
--------------------------------------------------------------------------------
/.github/workflows/docs.yaml:
--------------------------------------------------------------------------------
1 | name: Docs
2 |
3 | on:
4 | pull_request:
5 | workflow_dispatch:
6 |
7 | jobs:
8 | docs:
9 | runs-on: ubuntu-latest
10 | name: Docs
11 |
12 | steps:
13 | - name: Checkout repository
14 | uses: actions/checkout@v4
15 |
16 | - name: Fetch all tags and branches
17 | run: git fetch --prune --unshallow
18 |
19 | - name: Set up Python
20 | uses: actions/setup-python@v4
21 | with:
22 | python-version: '3.12'
23 |
24 | - name: Install dependencies
25 | run: pip install .[dev]
26 |
27 | - name: Run Sphinx doctest
28 | run: python -m sphinx -b doctest docs docs/_build
29 |
--------------------------------------------------------------------------------
/.github/workflows/pypi.yaml:
--------------------------------------------------------------------------------
1 | name: Upload to PyPI
2 |
3 | on:
4 | release:
5 | types: [published]
6 |
7 | jobs:
8 | upload:
9 | runs-on: ubuntu-latest
10 |
11 | steps:
12 | - name: Checkout repository
13 | uses: actions/checkout@v3
14 | with:
15 | fetch-depth: 0
16 | fetch-tags: true
17 |
18 | - uses: actions/setup-python@v4
19 | with:
20 | python-version: "3.12"
21 |
22 | - name: Install build dependencies
23 | run: |
24 | python -m pip install --upgrade pip
25 | pip install build twine
26 |
27 | - name: Build package
28 | run: python -m build
29 |
30 | - name: Publish to PyPI Test
31 | env:
32 | TWINE_USERNAME: __token__
33 | TWINE_PASSWORD: ${{ secrets.PYPI_TEST_TOKEN }}
34 | run: twine upload --repository testpypi dist/*
35 |
36 | - name: Publish to PyPI
37 | env:
38 | TWINE_USERNAME: __token__
39 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
40 | run: twine upload --repository pypi dist/*
41 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # poetry
98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99 | # This is especially recommended for binary packages to ensure reproducibility, and is more
100 | # commonly ignored for libraries.
101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102 | #poetry.lock
103 |
104 | # pdm
105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106 | #pdm.lock
107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108 | # in version control.
109 | # https://pdm.fming.dev/#use-with-ide
110 | .pdm.toml
111 |
112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
113 | __pypackages__/
114 |
115 | # Celery stuff
116 | celerybeat-schedule
117 | celerybeat.pid
118 |
119 | # SageMath parsed files
120 | *.sage.py
121 |
122 | # Environments
123 | .env
124 | .venv
125 | env/
126 | venv/
127 | ENV/
128 | env.bak/
129 | venv.bak/
130 |
131 | # Spyder project settings
132 | .spyderproject
133 | .spyproject
134 |
135 | # Rope project settings
136 | .ropeproject
137 |
138 | # mkdocs documentation
139 | /site
140 |
141 | # mypy
142 | .mypy_cache/
143 | .dmypy.json
144 | dmypy.json
145 |
146 | # Pyre type checker
147 | .pyre/
148 |
149 | # pytype static type analyzer
150 | .pytype/
151 |
152 | # Cython debug symbols
153 | cython_debug/
154 |
155 | # PyCharm
156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
158 | # and can be added to the global gitignore or merged into this file. For a more nuclear
159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
160 | .idea/
161 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: https://github.com/pre-commit/pre-commit-hooks
3 | rev: v4.4.0
4 | # See https://pre-commit.com/hooks.html
5 | hooks:
6 | - id: check-case-conflict
7 | - id: check-executables-have-shebangs
8 | - id: check-merge-conflict
9 | - id: check-symlinks
10 | - id: check-toml
11 | - id: check-xml
12 | - id: check-yaml
13 | - id: debug-statements
14 | - id: detect-private-key
15 | - id: end-of-file-fixer
16 | - id: mixed-line-ending
17 | - id: pretty-format-json
18 | - id: trailing-whitespace
19 |
20 | - repo: https://github.com/psf/black
21 | rev: 24.4.2
22 | hooks:
23 | - id: black
24 |
25 | - repo: https://github.com/PyCQA/isort
26 | rev: 5.12.0
27 | hooks:
28 | - id: isort
29 | args: [ --profile, black ]
30 |
31 | - repo: https://github.com/PyCQA/flake8
32 | rev: 7.0.0
33 | hooks:
34 | - id: flake8
35 |
36 | - repo: https://github.com/pre-commit/mirrors-mypy
37 | rev: v1.10.0
38 | hooks:
39 | - id: mypy
40 | files: ^arangoasync/
41 | additional_dependencies: ["types-requests", "types-setuptools"]
42 |
--------------------------------------------------------------------------------
/.readthedocs.yaml:
--------------------------------------------------------------------------------
1 | # .readthedocs.yaml
2 | # Read the Docs configuration file
3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
4 |
5 | # Required
6 | version: 2
7 |
8 | # Set the OS, Python version and other tools you might need
9 | build:
10 | os: ubuntu-22.04
11 | tools:
12 | python: "3.12"
13 |
14 | # Build documentation in the "docs/" directory with Sphinx
15 | sphinx:
16 | configuration: docs/conf.py
17 | fail_on_warning: true
18 |
19 | # Optional but recommended, declare the Python requirements required
20 | # to build your documentation
21 | # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
22 | python:
23 | install:
24 | - method: pip
25 | path: .[dev]
26 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | Set up dev environment:
4 | ```shell
5 | cd ~/your/repository/fork # Activate venv if you have one (recommended)
6 | pip install -e .[dev] # Install dev dependencies (e.g. black, mypy, pre-commit)
7 | pre-commit install # Install git pre-commit hooks
8 | ```
9 |
10 | Run unit tests with coverage:
11 |
12 | ```shell
13 | pytest --cov=arango --cov-report=html # Open htmlcov/index.html in your browser
14 | ```
15 |
16 | To start and ArangoDB instance locally, run:
17 |
18 | ```shell
19 | ./starter.sh # Requires docker
20 | ```
21 |
22 | Build and test documentation:
23 |
24 | ```shell
25 | python -m sphinx docs docs/_build # Open docs/_build/index.html in your browser
26 | ```
27 |
28 | Thank you for your contribution!
29 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 ArangoDB
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | [](https://dl.circleci.com/status-badge/redirect/gh/arangodb/python-arango-async/tree/main)
4 | [](https://github.com/arangodb/python-arango-async/actions/workflows/codeql.yaml)
5 | [](https://github.com/arangodb/python-arango-async/commits/main)
6 |
7 | [](https://pypi.org/project/python-arango-async/)
8 | [](https://pypi.org/project/python-arango-async/)
9 |
10 | [](https://github.com/arangodb/python-arango/blob/main/LICENSE)
11 | [](https://github.com/psf/black)
12 | [](https://pepy.tech/project/python-arango-async)
14 |
15 | # python-arango-async
16 |
17 | Python driver for [ArangoDB](https://www.arangodb.com), a scalable multi-model
18 | database natively supporting documents, graphs and search.
19 |
20 | This is the _asyncio_ alternative of the [python-arango](https://github.com/arangodb/python-arango)
21 | driver.
22 |
23 | **Note: This project is still in active development, features might be added or removed.**
24 |
25 | ## Requirements
26 |
27 | - ArangoDB version 3.11+
28 | - Python version 3.10+
29 |
30 | ## Installation
31 |
32 | ```shell
33 | pip install python-arango-async --upgrade
34 | ```
35 |
36 | ## Getting Started
37 |
38 | Here is a simple usage example:
39 |
40 | ```python
41 | from arangoasync import ArangoClient
42 | from arangoasync.auth import Auth
43 |
44 |
45 | async def main():
46 | # Initialize the client for ArangoDB.
47 | async with ArangoClient(hosts="http://localhost:8529") as client:
48 | auth = Auth(username="root", password="passwd")
49 |
50 | # Connect to "_system" database as root user.
51 | sys_db = await client.db("_system", auth=auth)
52 |
53 | # Create a new database named "test".
54 | await sys_db.create_database("test")
55 |
56 | # Connect to "test" database as root user.
57 | db = await client.db("test", auth=auth)
58 |
59 | # Create a new collection named "students".
60 | students = await db.create_collection("students")
61 |
62 | # Add a persistent index to the collection.
63 | await students.add_index(type="persistent", fields=["name"], options={"unique": True})
64 |
65 | # Insert new documents into the collection.
66 | await students.insert({"name": "jane", "age": 39})
67 | await students.insert({"name": "josh", "age": 18})
68 | await students.insert({"name": "judy", "age": 21})
69 |
70 | # Execute an AQL query and iterate through the result cursor.
71 | cursor = await db.aql.execute("FOR doc IN students RETURN doc")
72 | async with cursor:
73 | student_names = []
74 | async for doc in cursor:
75 | student_names.append(doc["name"])
76 | ```
77 |
78 | Another example with [graphs](https://docs.arangodb.com/stable/graphs/):
79 |
80 | ```python
81 | async def main():
82 | from arangoasync import ArangoClient
83 | from arangoasync.auth import Auth
84 |
85 | # Initialize the client for ArangoDB.
86 | async with ArangoClient(hosts="http://localhost:8529") as client:
87 | auth = Auth(username="root", password="passwd")
88 |
89 | # Connect to "test" database as root user.
90 | db = await client.db("test", auth=auth)
91 |
92 | # Get the API wrapper for graph "school".
93 | if await db.has_graph("school"):
94 | graph = db.graph("school")
95 | else:
96 | graph = await db.create_graph("school")
97 |
98 | # Create vertex collections for the graph.
99 | students = await graph.create_vertex_collection("students")
100 | lectures = await graph.create_vertex_collection("lectures")
101 |
102 | # Create an edge definition (relation) for the graph.
103 | edges = await graph.create_edge_definition(
104 | edge_collection="register",
105 | from_vertex_collections=["students"],
106 | to_vertex_collections=["lectures"]
107 | )
108 |
109 | # Insert vertex documents into "students" (from) vertex collection.
110 | await students.insert({"_key": "01", "full_name": "Anna Smith"})
111 | await students.insert({"_key": "02", "full_name": "Jake Clark"})
112 | await students.insert({"_key": "03", "full_name": "Lisa Jones"})
113 |
114 | # Insert vertex documents into "lectures" (to) vertex collection.
115 | await lectures.insert({"_key": "MAT101", "title": "Calculus"})
116 | await lectures.insert({"_key": "STA101", "title": "Statistics"})
117 | await lectures.insert({"_key": "CSC101", "title": "Algorithms"})
118 |
119 | # Insert edge documents into "register" edge collection.
120 | await edges.insert({"_from": "students/01", "_to": "lectures/MAT101"})
121 | await edges.insert({"_from": "students/01", "_to": "lectures/STA101"})
122 | await edges.insert({"_from": "students/01", "_to": "lectures/CSC101"})
123 | await edges.insert({"_from": "students/02", "_to": "lectures/MAT101"})
124 | await edges.insert({"_from": "students/02", "_to": "lectures/STA101"})
125 | await edges.insert({"_from": "students/03", "_to": "lectures/CSC101"})
126 |
127 | # Traverse the graph in outbound direction, breath-first.
128 | query = """
129 | FOR v, e, p IN 1..3 OUTBOUND 'students/01' GRAPH 'school'
130 | OPTIONS { bfs: true, uniqueVertices: 'global' }
131 | RETURN {vertex: v, edge: e, path: p}
132 | """
133 |
134 | async with await db.aql.execute(query) as cursor:
135 | async for doc in cursor:
136 | print(doc)
137 | ```
138 |
139 | Please see the [documentation](https://python-arango-async.readthedocs.io/en/latest/) for more details.
140 |
--------------------------------------------------------------------------------
/arangoasync/__init__.py:
--------------------------------------------------------------------------------
1 | import arangoasync.errno as errno # noqa: F401
2 | from arangoasync.client import ArangoClient # noqa: F401
3 | from arangoasync.exceptions import * # noqa: F401 F403
4 |
5 | from .version import __version__
6 |
--------------------------------------------------------------------------------
/arangoasync/auth.py:
--------------------------------------------------------------------------------
1 | __all__ = [
2 | "Auth",
3 | "JwtToken",
4 | ]
5 |
6 | import time
7 | from dataclasses import dataclass
8 | from typing import Optional
9 |
10 | import jwt
11 |
12 |
13 | @dataclass
14 | class Auth:
15 | """Authentication details for the ArangoDB instance.
16 |
17 | Attributes:
18 | username (str): Username.
19 | password (str): Password.
20 | encoding (str): Encoding for the password (default: utf-8)
21 | """
22 |
23 | username: str
24 | password: str
25 | encoding: str = "utf-8"
26 |
27 |
28 | class JwtToken:
29 | """JWT token.
30 |
31 | Args:
32 | token (str): JWT token.
33 |
34 | Raises:
35 | TypeError: If the token type is not str or bytes.
36 | jwt.exceptions.ExpiredSignatureError: If the token expired.
37 | """
38 |
39 | def __init__(self, token: str) -> None:
40 | self._token = token
41 | self._validate()
42 |
43 | @staticmethod
44 | def generate_token(
45 | secret: str | bytes,
46 | iat: Optional[int] = None,
47 | exp: int = 3600,
48 | iss: str = "arangodb",
49 | server_id: str = "client",
50 | ) -> "JwtToken":
51 | """Generate and return a JWT token.
52 |
53 | Args:
54 | secret (str | bytes): JWT secret.
55 | iat (int): Time the token was issued in seconds. Defaults to current time.
56 | exp (int): Time to expire in seconds.
57 | iss (str): Issuer.
58 | server_id (str): Server ID.
59 |
60 | Returns:
61 | str: JWT token.
62 | """
63 | iat = iat or int(time.time())
64 | token = jwt.encode(
65 | payload={
66 | "iat": iat,
67 | "exp": iat + exp,
68 | "iss": iss,
69 | "server_id": server_id,
70 | },
71 | key=secret,
72 | )
73 | return JwtToken(token)
74 |
75 | @property
76 | def token(self) -> str:
77 | """Get token."""
78 | return self._token
79 |
80 | @token.setter
81 | def token(self, token: str) -> None:
82 | """Set token.
83 |
84 | Raises:
85 | jwt.exceptions.ExpiredSignatureError: If the token expired.
86 | """
87 | self._token = token
88 | self._validate()
89 |
90 | def needs_refresh(self, leeway: int = 0) -> bool:
91 | """Check if the token needs to be refreshed.
92 |
93 | Args:
94 | leeway (int): Leeway in seconds, before official expiration,
95 | when to consider the token expired.
96 |
97 | Returns:
98 | bool: True if the token needs to be refreshed, False otherwise.
99 | """
100 | refresh: bool = int(time.time()) > self._token_exp - leeway
101 | return refresh
102 |
103 | def _validate(self) -> None:
104 | """Validate the token."""
105 | if type(self._token) is not str:
106 | raise TypeError("Token must be str")
107 |
108 | jwt_payload = jwt.decode(
109 | self._token,
110 | issuer="arangodb",
111 | algorithms=["HS256"],
112 | options={
113 | "require_exp": True,
114 | "require_iat": True,
115 | "verify_iat": True,
116 | "verify_exp": True,
117 | "verify_signature": False,
118 | },
119 | )
120 |
121 | self._token_exp = jwt_payload["exp"]
122 |
--------------------------------------------------------------------------------
/arangoasync/compression.py:
--------------------------------------------------------------------------------
1 | __all__ = [
2 | "AcceptEncoding",
3 | "ContentEncoding",
4 | "CompressionManager",
5 | "DefaultCompressionManager",
6 | ]
7 |
8 | import zlib
9 | from abc import ABC, abstractmethod
10 | from enum import Enum, auto
11 | from typing import Optional
12 |
13 |
14 | class AcceptEncoding(Enum):
15 | """Valid accepted encodings for the Accept-Encoding header."""
16 |
17 | DEFLATE = auto()
18 | GZIP = auto()
19 | IDENTITY = auto()
20 |
21 |
22 | class ContentEncoding(Enum):
23 | """Valid content encodings for the Content-Encoding header."""
24 |
25 | DEFLATE = auto()
26 | GZIP = auto()
27 |
28 |
29 | class CompressionManager(ABC): # pragma: no cover
30 | """Abstract base class for handling request/response compression."""
31 |
32 | @abstractmethod
33 | def needs_compression(self, data: str | bytes) -> bool:
34 | """Determine if the data needs to be compressed
35 |
36 | Args:
37 | data (str | bytes): Data to check
38 |
39 | Returns:
40 | bool: True if the data needs to be compressed
41 | """
42 | raise NotImplementedError
43 |
44 | @abstractmethod
45 | def compress(self, data: str | bytes) -> bytes:
46 | """Compress the data
47 |
48 | Args:
49 | data (str | bytes): Data to compress
50 |
51 | Returns:
52 | bytes: Compressed data
53 | """
54 | raise NotImplementedError
55 |
56 | @property
57 | @abstractmethod
58 | def content_encoding(self) -> str:
59 | """Return the content encoding.
60 |
61 | This is the value of the Content-Encoding header in the HTTP request.
62 | Must match the encoding used in the compress method.
63 |
64 | Returns:
65 | str: Content encoding
66 | """
67 | raise NotImplementedError
68 |
69 | @property
70 | @abstractmethod
71 | def accept_encoding(self) -> str | None:
72 | """Return the accept encoding.
73 |
74 | This is the value of the Accept-Encoding header in the HTTP request.
75 | Currently, only "deflate" and "gzip" are supported.
76 |
77 | Returns:
78 | str: Accept encoding
79 | """
80 | raise NotImplementedError
81 |
82 |
83 | class DefaultCompressionManager(CompressionManager):
84 | """Compress requests using the deflate algorithm.
85 |
86 | Args:
87 | threshold (int): Will compress requests to the server if
88 | the size of the request body (in bytes) is at least the value of this option.
89 | Setting it to -1 will disable request compression.
90 | level (int): Compression level. Defaults to 6.
91 | accept (str | None): Accepted encoding. Can be disabled by setting it to `None`.
92 | """
93 |
94 | def __init__(
95 | self,
96 | threshold: int = 1024,
97 | level: int = 6,
98 | accept: Optional[AcceptEncoding] = AcceptEncoding.DEFLATE,
99 | ) -> None:
100 | self._threshold = threshold
101 | self._level = level
102 | self._content_encoding = ContentEncoding.DEFLATE.name.lower()
103 | self._accept_encoding = accept.name.lower() if accept else None
104 |
105 | @property
106 | def threshold(self) -> int:
107 | return self._threshold
108 |
109 | @threshold.setter
110 | def threshold(self, value: int) -> None:
111 | self._threshold = value
112 |
113 | @property
114 | def level(self) -> int:
115 | return self._level
116 |
117 | @level.setter
118 | def level(self, value: int) -> None:
119 | self._level = value
120 |
121 | @property
122 | def accept_encoding(self) -> Optional[str]:
123 | return self._accept_encoding
124 |
125 | @accept_encoding.setter
126 | def accept_encoding(self, value: Optional[AcceptEncoding]) -> None:
127 | self._accept_encoding = value.name.lower() if value else None
128 |
129 | @property
130 | def content_encoding(self) -> str:
131 | return self._content_encoding
132 |
133 | def needs_compression(self, data: str | bytes) -> bool:
134 | return len(data) >= self._threshold
135 |
136 | def compress(self, data: str | bytes) -> bytes:
137 | if isinstance(data, bytes):
138 | return zlib.compress(data, self._level)
139 | return zlib.compress(data.encode("utf-8"), self._level)
140 |
--------------------------------------------------------------------------------
/arangoasync/executor.py:
--------------------------------------------------------------------------------
1 | __all__ = [
2 | "ApiExecutor",
3 | "DefaultApiExecutor",
4 | "NonAsyncExecutor",
5 | "TransactionApiExecutor",
6 | "AsyncApiExecutor",
7 | ]
8 |
9 | from typing import Callable, Optional, TypeVar
10 |
11 | from arangoasync.connection import Connection
12 | from arangoasync.exceptions import AsyncExecuteError
13 | from arangoasync.job import AsyncJob
14 | from arangoasync.request import Request
15 | from arangoasync.response import Response
16 | from arangoasync.serialization import Deserializer, Serializer
17 | from arangoasync.typings import Json, Jsons
18 |
19 | T = TypeVar("T")
20 |
21 |
22 | class ExecutorContext:
23 | """Base class for API executors.
24 |
25 | Not to be exported publicly.
26 |
27 | Args:
28 | connection: HTTP connection.
29 | """
30 |
31 | def __init__(self, connection: Connection) -> None:
32 | self._conn = connection
33 |
34 | @property
35 | def connection(self) -> Connection:
36 | return self._conn
37 |
38 | @property
39 | def db_name(self) -> str:
40 | return self._conn.db_name
41 |
42 | @property
43 | def serializer(self) -> Serializer[Json]:
44 | return self._conn.serializer
45 |
46 | @property
47 | def deserializer(self) -> Deserializer[Json, Jsons]:
48 | return self._conn.deserializer
49 |
50 | def serialize(self, data: Json) -> str:
51 | return self.serializer.dumps(data)
52 |
53 | def deserialize(self, data: bytes) -> Json:
54 | return self.deserializer.loads(data)
55 |
56 |
57 | class DefaultApiExecutor(ExecutorContext):
58 | """Default API executor.
59 |
60 | Responsible for executing requests and handling responses.
61 |
62 | Args:
63 | connection: HTTP connection.
64 | """
65 |
66 | def __init__(self, connection: Connection) -> None:
67 | super().__init__(connection)
68 |
69 | @property
70 | def context(self) -> str:
71 | return "default"
72 |
73 | async def execute(
74 | self, request: Request, response_handler: Callable[[Response], T]
75 | ) -> T:
76 | """Execute the request and handle the response.
77 |
78 | Args:
79 | request: HTTP request.
80 | response_handler: HTTP response handler.
81 | """
82 | response = await self._conn.send_request(request)
83 | return response_handler(response)
84 |
85 |
86 | class TransactionApiExecutor(ExecutorContext):
87 | """Executes transaction API requests.
88 |
89 | Args:
90 | connection: HTTP connection.
91 | transaction_id: str: Transaction ID generated by the server.
92 | """
93 |
94 | def __init__(self, connection: Connection, transaction_id: str) -> None:
95 | super().__init__(connection)
96 | self._id = transaction_id
97 |
98 | @property
99 | def context(self) -> str:
100 | return "transaction"
101 |
102 | @property
103 | def id(self) -> str:
104 | """Return the transaction ID."""
105 | return self._id
106 |
107 | async def execute(
108 | self, request: Request, response_handler: Callable[[Response], T]
109 | ) -> T:
110 | """Execute the request and handle the response.
111 |
112 | Args:
113 | request: HTTP request.
114 | response_handler: HTTP response handler.
115 | """
116 | request.headers["x-arango-trx-id"] = self.id
117 | response = await self._conn.send_request(request)
118 | return response_handler(response)
119 |
120 |
121 | class AsyncApiExecutor(ExecutorContext):
122 | """Executes asynchronous API requests (jobs).
123 |
124 | Args:
125 | connection: HTTP connection.
126 | return_result: If set to `True`, API executions return instances of
127 | :class:`arangoasync.job.AsyncJob` and results can be retrieved from server
128 | once available. If set to `False`, API executions return `None` and no
129 | results are stored on server.
130 | """
131 |
132 | def __init__(self, connection: Connection, return_result: bool) -> None:
133 | super().__init__(connection)
134 | self._return_result = return_result
135 |
136 | @property
137 | def context(self) -> str:
138 | return "async"
139 |
140 | async def execute(
141 | self, request: Request, response_handler: Callable[[Response], T]
142 | ) -> Optional[AsyncJob[T]]:
143 | """Execute an API request asynchronously.
144 |
145 | Args:
146 | request: HTTP request.
147 | response_handler: HTTP response handler.
148 |
149 | Returns: `AsyncJob` job or `None` if **return_result** parameter was set to
150 | `False` during initialization.
151 | """
152 | if self._return_result:
153 | request.headers["x-arango-async"] = "store"
154 | else:
155 | request.headers["x-arango-async"] = "true"
156 |
157 | response = await self._conn.send_request(request)
158 | if not response.is_success:
159 | raise AsyncExecuteError(response, request)
160 | if not self._return_result:
161 | return None
162 |
163 | job_id = response.headers["x-arango-async-id"]
164 | return AsyncJob(self._conn, job_id, response_handler)
165 |
166 |
167 | ApiExecutor = DefaultApiExecutor | TransactionApiExecutor | AsyncApiExecutor
168 | NonAsyncExecutor = DefaultApiExecutor | TransactionApiExecutor
169 |
--------------------------------------------------------------------------------
/arangoasync/http.py:
--------------------------------------------------------------------------------
1 | __all__ = [
2 | "HTTPClient",
3 | "AioHTTPClient",
4 | "DefaultHTTPClient",
5 | ]
6 |
7 | from abc import ABC, abstractmethod
8 | from ssl import SSLContext, create_default_context
9 | from typing import Any, Optional
10 |
11 | from aiohttp import (
12 | BaseConnector,
13 | BasicAuth,
14 | ClientSession,
15 | ClientTimeout,
16 | TCPConnector,
17 | client_exceptions,
18 | )
19 |
20 | from arangoasync.exceptions import ClientConnectionError
21 | from arangoasync.request import Request
22 | from arangoasync.response import Response
23 |
24 |
25 | class HTTPClient(ABC): # pragma: no cover
26 | """Abstract base class for HTTP clients.
27 |
28 | Custom HTTP clients should inherit from this class.
29 |
30 | Example:
31 | .. code-block:: python
32 |
33 | class MyCustomHTTPClient(HTTPClient):
34 | def create_session(self, host):
35 | pass
36 | async def close_session(self, session):
37 | pass
38 | async def send_request(self, session, request):
39 | pass
40 | """
41 |
42 | @abstractmethod
43 | def create_session(self, host: str) -> Any:
44 | """Return a new session given the base host URL.
45 |
46 | Note:
47 | This method must be overridden by the user.
48 |
49 | Args:
50 | host (str): ArangoDB host URL.
51 |
52 | Returns:
53 | Requests session object.
54 | """
55 | raise NotImplementedError
56 |
57 | @abstractmethod
58 | async def close_session(self, session: Any) -> None:
59 | """Close the session.
60 |
61 | Note:
62 | This method must be overridden by the user.
63 |
64 | Args:
65 | session (Any): Client session object.
66 | """
67 | raise NotImplementedError
68 |
69 | @abstractmethod
70 | async def send_request(
71 | self,
72 | session: Any,
73 | request: Request,
74 | ) -> Response:
75 | """Send an HTTP request.
76 |
77 | Note:
78 | This method must be overridden by the user.
79 |
80 | Args:
81 | session (Any): Client session object.
82 | request (Request): HTTP request.
83 |
84 | Returns:
85 | Response: HTTP response.
86 | """
87 | raise NotImplementedError
88 |
89 |
90 | class AioHTTPClient(HTTPClient):
91 | """HTTP client implemented on top of aiohttp_.
92 |
93 | Args:
94 | connector (aiohttp.BaseConnector | None): Supports connection pooling.
95 | By default, 100 simultaneous connections are supported, with a 60-second
96 | timeout for connection reusing after release.
97 | timeout (aiohttp.ClientTimeout | None): Client timeout settings.
98 | 300s total timeout by default for a complete request/response operation.
99 | read_bufsize (int): Size of read buffer (64KB default).
100 | ssl_context (ssl.SSLContext | bool): SSL validation mode.
101 | `True` for default SSL checks (see :func:`ssl.create_default_context`).
102 | `False` disables SSL checks.
103 | Additionally, you can pass a custom :class:`ssl.SSLContext`.
104 |
105 | .. _aiohttp:
106 | https://docs.aiohttp.org/en/stable/
107 | """
108 |
109 | def __init__(
110 | self,
111 | connector: Optional[BaseConnector] = None,
112 | timeout: Optional[ClientTimeout] = None,
113 | read_bufsize: int = 2**16,
114 | ssl_context: bool | SSLContext = True,
115 | ) -> None:
116 | self._connector = connector or TCPConnector(
117 | keepalive_timeout=60, # timeout for connection reusing after releasing
118 | limit=100, # total number simultaneous connections
119 | )
120 | self._timeout = timeout or ClientTimeout(
121 | total=300, # total number of seconds for the whole request
122 | connect=60, # max number of seconds for acquiring a pool connection
123 | )
124 | self._read_bufsize = read_bufsize
125 | self._ssl_context = (
126 | ssl_context if ssl_context is not True else create_default_context()
127 | )
128 |
129 | def create_session(self, host: str) -> ClientSession:
130 | """Return a new session given the base host URL.
131 |
132 | Args:
133 | host (str): ArangoDB host URL. Must not include any paths. Typically, this
134 | is the address and port of a coordinator (e.g. "http://127.0.0.1:8529").
135 |
136 | Returns:
137 | aiohttp.ClientSession: Session object, used to send future requests.
138 | """
139 | return ClientSession(
140 | base_url=host,
141 | connector=self._connector,
142 | timeout=self._timeout,
143 | read_bufsize=self._read_bufsize,
144 | )
145 |
146 | async def close_session(self, session: ClientSession) -> None:
147 | """Close the session.
148 |
149 | Args:
150 | session (Any): Client session object.
151 | """
152 | await session.close()
153 |
154 | async def send_request(
155 | self,
156 | session: ClientSession,
157 | request: Request,
158 | ) -> Response:
159 | """Send an HTTP request.
160 |
161 | Args:
162 | session (aiohttp.ClientSession): Session object used to make the request.
163 | request (Request): HTTP request.
164 |
165 | Returns:
166 | Response: HTTP response.
167 |
168 | Raises:
169 | ClientConnectionError: If the request fails.
170 | """
171 |
172 | if request.auth is not None:
173 | auth = BasicAuth(
174 | login=request.auth.username,
175 | password=request.auth.password,
176 | encoding=request.auth.encoding,
177 | )
178 | else:
179 | auth = None
180 |
181 | try:
182 | async with session.request(
183 | request.method.name,
184 | request.endpoint,
185 | headers=request.normalized_headers(),
186 | params=request.normalized_params(),
187 | data=request.data,
188 | auth=auth,
189 | ssl=self._ssl_context,
190 | ) as response:
191 | raw_body = await response.read()
192 | return Response(
193 | method=request.method,
194 | url=str(response.real_url),
195 | headers=response.headers,
196 | status_code=response.status,
197 | status_text=str(response.reason),
198 | raw_body=raw_body,
199 | )
200 | except client_exceptions.ClientConnectionError as e:
201 | raise ClientConnectionError(str(e)) from e
202 |
203 |
204 | DefaultHTTPClient = AioHTTPClient
205 |
--------------------------------------------------------------------------------
/arangoasync/job.py:
--------------------------------------------------------------------------------
1 | __all__ = ["AsyncJob"]
2 |
3 |
4 | import asyncio
5 | from typing import Callable, Generic, Optional, TypeVar
6 |
7 | from arangoasync.connection import Connection
8 | from arangoasync.errno import HTTP_NOT_FOUND
9 | from arangoasync.exceptions import (
10 | AsyncJobCancelError,
11 | AsyncJobClearError,
12 | AsyncJobResultError,
13 | AsyncJobStatusError,
14 | )
15 | from arangoasync.request import Method, Request
16 | from arangoasync.response import Response
17 |
18 | T = TypeVar("T")
19 |
20 |
21 | class AsyncJob(Generic[T]):
22 | """Job for tracking and retrieving result of an async API execution.
23 |
24 | Args:
25 | conn: HTTP connection.
26 | job_id: Async job ID.
27 | response_handler: HTTP response handler
28 |
29 | References:
30 | - `jobs `__
31 | """ # noqa: E501
32 |
33 | def __init__(
34 | self,
35 | conn: Connection,
36 | job_id: str,
37 | response_handler: Callable[[Response], T],
38 | ) -> None:
39 | self._conn = conn
40 | self._id = job_id
41 | self._response_handler = response_handler
42 |
43 | def __repr__(self) -> str:
44 | return f""
45 |
46 | @property
47 | def id(self) -> str:
48 | """Return the async job ID.
49 |
50 | Returns:
51 | str: Async job ID.
52 | """
53 | return self._id
54 |
55 | async def status(self) -> str:
56 | """Return the async job status from server.
57 |
58 | Once a job result is retrieved via func:`arangoasync.job.AsyncJob.result`
59 | method, it is deleted from server and subsequent status queries will
60 | fail.
61 |
62 | Returns:
63 | str: Async job status. Possible values are "pending" (job is still
64 | in queue), "done" (job finished or raised an error).
65 |
66 | Raises:
67 | ArangoError: If there is a problem with the request.
68 | AsyncJobStatusError: If retrieval fails or the job is not found.
69 |
70 | References:
71 | - `list-async-jobs-by-status-or-get-the-status-of-specific-job `__
72 | """ # noqa: E501
73 | request = Request(method=Method.GET, endpoint=f"/_api/job/{self._id}")
74 | response = await self._conn.send_request(request)
75 |
76 | if response.is_success:
77 | if response.status_code == 204:
78 | return "pending"
79 | else:
80 | return "done"
81 | if response.error_code == HTTP_NOT_FOUND:
82 | error_message = f"job {self._id} not found"
83 | raise AsyncJobStatusError(response, request, error_message)
84 | raise AsyncJobStatusError(response, request)
85 |
86 | async def result(self) -> T:
87 | """Fetch the async job result from server.
88 |
89 | If the job raised an exception, it is propagated up at this point.
90 |
91 | Once job result is retrieved, it is deleted from server and subsequent
92 | queries for result will fail.
93 |
94 | Returns:
95 | Async job result.
96 |
97 | Raises:
98 | ArangoError: If the job raised an exception or there was a problem with
99 | the request.
100 | AsyncJobResultError: If retrieval fails, because job no longer exists or
101 | is still pending.
102 |
103 | References:
104 | - `get-the-results-of-an-async-job `__
105 | """ # noqa: E501
106 | request = Request(method=Method.PUT, endpoint=f"/_api/job/{self._id}")
107 | response = await self._conn.send_request(request)
108 |
109 | if (
110 | "x-arango-async-id" in response.headers
111 | or "X-Arango-Async-Id" in response.headers
112 | ):
113 | # The job result is available on the server
114 | return self._response_handler(response)
115 |
116 | if response.status_code == 204:
117 | # The job is still in the pending queue or not yet finished.
118 | raise AsyncJobResultError(response, request, self._not_done())
119 | # The job is not known (anymore).
120 | # We can tell the status from the HTTP status code.
121 | if response.error_code == HTTP_NOT_FOUND:
122 | raise AsyncJobResultError(response, request, self._not_found())
123 | raise AsyncJobResultError(response, request)
124 |
125 | async def cancel(self, ignore_missing: bool = False) -> bool:
126 | """Cancel the async job.
127 |
128 | An async job cannot be cancelled once it is taken out of the queue.
129 |
130 | Note:
131 | It still might take some time to actually cancel the running async job.
132 |
133 | Args:
134 | ignore_missing: Do not raise an exception if the job is not found.
135 |
136 | Returns:
137 | `True` if job was cancelled successfully, `False` if the job was not found
138 | but **ignore_missing** was set to `True`.
139 |
140 | Raises:
141 | ArangoError: If there was a problem with the request.
142 | AsyncJobCancelError: If cancellation fails.
143 |
144 | References:
145 | - `cancel-an-async-job `__
146 | """ # noqa: E501
147 | request = Request(method=Method.PUT, endpoint=f"/_api/job/{self._id}/cancel")
148 | response = await self._conn.send_request(request)
149 |
150 | if response.is_success:
151 | return True
152 | if response.error_code == HTTP_NOT_FOUND:
153 | if ignore_missing:
154 | return False
155 | raise AsyncJobCancelError(response, request, self._not_found())
156 | raise AsyncJobCancelError(response, request)
157 |
158 | async def clear(
159 | self,
160 | ignore_missing: bool = False,
161 | ) -> bool:
162 | """Delete the job result from the server.
163 |
164 | Args:
165 | ignore_missing: Do not raise an exception if the job is not found.
166 |
167 | Returns:
168 | `True` if result was deleted successfully, `False` if the job was
169 | not found but **ignore_missing** was set to `True`.
170 |
171 | Raises:
172 | ArangoError: If there was a problem with the request.
173 | AsyncJobClearError: If deletion fails.
174 |
175 | References:
176 | - `delete-async-job-results `__
177 | """ # noqa: E501
178 | request = Request(method=Method.DELETE, endpoint=f"/_api/job/{self._id}")
179 | resp = await self._conn.send_request(request)
180 |
181 | if resp.is_success:
182 | return True
183 | if resp.error_code == HTTP_NOT_FOUND:
184 | if ignore_missing:
185 | return False
186 | raise AsyncJobClearError(resp, request, self._not_found())
187 | raise AsyncJobClearError(resp, request)
188 |
189 | async def wait(self, seconds: Optional[float] = None) -> bool:
190 | """Wait for the async job to finish.
191 |
192 | Args:
193 | seconds: Number of seconds to wait between status checks. If not
194 | provided, the method will wait indefinitely.
195 |
196 | Returns:
197 | `True` if the job is done, `False` if the job is still pending.
198 | """
199 | while True:
200 | if await self.status() == "done":
201 | return True
202 | if seconds is None:
203 | await asyncio.sleep(1)
204 | else:
205 | seconds -= 1
206 | if seconds < 0:
207 | return False
208 | await asyncio.sleep(1)
209 |
210 | def _not_found(self) -> str:
211 | return f"job {self._id} not found"
212 |
213 | def _not_done(self) -> str:
214 | return f"job {self._id} not done"
215 |
--------------------------------------------------------------------------------
/arangoasync/logger.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | logger = logging.getLogger("arangoasync")
4 |
--------------------------------------------------------------------------------
/arangoasync/request.py:
--------------------------------------------------------------------------------
1 | __all__ = [
2 | "Method",
3 | "Request",
4 | ]
5 |
6 | from enum import Enum, auto
7 | from typing import Optional
8 |
9 | from arangoasync.auth import Auth
10 | from arangoasync.typings import Params, RequestHeaders
11 | from arangoasync.version import __version__
12 |
13 |
14 | class Method(Enum):
15 | """HTTP methods enum: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS"""
16 |
17 | GET = auto()
18 | POST = auto()
19 | PUT = auto()
20 | PATCH = auto()
21 | DELETE = auto()
22 | HEAD = auto()
23 | OPTIONS = auto()
24 |
25 |
26 | class Request:
27 | """HTTP request.
28 |
29 | Args:
30 | method (Method): HTTP method.
31 | endpoint (str): API endpoint.
32 | headers (dict | None): Request headers.
33 | params (dict | None): URL parameters.
34 | data (bytes | None): Request payload.
35 | auth (Auth | None): Authentication.
36 |
37 | Attributes:
38 | method (Method): HTTP method.
39 | endpoint (str): API endpoint.
40 | headers (dict | None): Request headers.
41 | params (dict | None): URL parameters.
42 | data (bytes | None): Request payload.
43 | auth (Auth | None): Authentication.
44 | """
45 |
46 | __slots__ = (
47 | "method",
48 | "endpoint",
49 | "headers",
50 | "params",
51 | "data",
52 | "auth",
53 | )
54 |
55 | def __init__(
56 | self,
57 | method: Method,
58 | endpoint: str,
59 | headers: Optional[RequestHeaders] = None,
60 | params: Optional[Params] = None,
61 | data: Optional[bytes | str] = None,
62 | auth: Optional[Auth] = None,
63 | ) -> None:
64 | self.method: Method = method
65 | self.endpoint: str = endpoint
66 | self.headers: RequestHeaders = headers or dict()
67 | self.params: Params = params or dict()
68 | self.data: Optional[bytes | str] = data
69 | self.auth: Optional[Auth] = auth
70 |
71 | def normalized_headers(self) -> RequestHeaders:
72 | """Normalize request headers.
73 |
74 | Returns:
75 | dict: Normalized request headers.
76 | """
77 | driver_header = f"arangoasync/{__version__}"
78 | normalized_headers: RequestHeaders = {
79 | "charset": "utf-8",
80 | "content-type": "application/json",
81 | "x-arango-driver": driver_header,
82 | }
83 |
84 | if self.headers is not None:
85 | for key, value in self.headers.items():
86 | normalized_headers[key.lower()] = value
87 |
88 | return normalized_headers
89 |
90 | def normalized_params(self) -> Params:
91 | """Normalize URL parameters.
92 |
93 | Returns:
94 | dict: Normalized URL parameters.
95 | """
96 | normalized_params: Params = {}
97 |
98 | if self.params is not None:
99 | for key, value in self.params.items():
100 | if isinstance(value, bool):
101 | value = int(value)
102 | normalized_params[key] = str(value)
103 |
104 | return normalized_params
105 |
106 | def __repr__(self) -> str:
107 | return f"<{self.method.name} {self.endpoint}>"
108 |
--------------------------------------------------------------------------------
/arangoasync/resolver.py:
--------------------------------------------------------------------------------
1 | __all__ = [
2 | "HostResolver",
3 | "SingleHostResolver",
4 | "RoundRobinHostResolver",
5 | "DefaultHostResolver",
6 | "get_resolver",
7 | ]
8 |
9 | from abc import ABC, abstractmethod
10 | from typing import List, Optional
11 |
12 |
13 | class HostResolver(ABC):
14 | """Abstract base class for host resolvers.
15 |
16 | Args:
17 | host_count (int): Number of hosts.
18 | max_tries (int | None): Maximum number of attempts to try a host.
19 | Will default to 3 times the number of hosts if not provided.
20 |
21 | Raises:
22 | ValueError: If max_tries is less than host_count.
23 | """
24 |
25 | def __init__(self, host_count: int = 1, max_tries: Optional[int] = None) -> None:
26 | max_tries = max_tries or host_count * 3
27 | if max_tries < host_count:
28 | raise ValueError(
29 | "The maximum number of attempts cannot be "
30 | "lower than the number of hosts."
31 | )
32 | self._host_count = host_count
33 | self._max_tries = max_tries
34 | self._index = 0
35 |
36 | @abstractmethod
37 | def get_host_index(self) -> int: # pragma: no cover
38 | """Return the index of the host to use.
39 |
40 | Returns:
41 | int: Index of the host.
42 | """
43 | raise NotImplementedError
44 |
45 | def change_host(self) -> None:
46 | """If there are multiple hosts available, switch to the next one."""
47 | self._index = (self._index + 1) % self.host_count
48 |
49 | @property
50 | def host_count(self) -> int:
51 | """Return the number of hosts."""
52 | return self._host_count
53 |
54 | @property
55 | def max_tries(self) -> int:
56 | """Return the maximum number of attempts."""
57 | return self._max_tries
58 |
59 |
60 | class SingleHostResolver(HostResolver):
61 | """Single host resolver.
62 |
63 | Always returns the same host index, unless prompted to change.
64 | """
65 |
66 | def __init__(self, host_count: int, max_tries: Optional[int] = None) -> None:
67 | super().__init__(host_count, max_tries)
68 |
69 | def get_host_index(self) -> int:
70 | return self._index
71 |
72 |
73 | class RoundRobinHostResolver(HostResolver):
74 | """Round-robin host resolver. Changes host every time.
75 |
76 | Useful for bulk inserts or updates.
77 |
78 | Note:
79 | Do not use this resolver for stream transactions.
80 | Transaction IDs cannot be shared across different coordinators.
81 | """
82 |
83 | def __init__(self, host_count: int, max_tries: Optional[int] = None) -> None:
84 | super().__init__(host_count, max_tries)
85 | self._index = -1
86 |
87 | def get_host_index(self, indexes_to_filter: Optional[List[int]] = None) -> int:
88 | self.change_host()
89 | return self._index
90 |
91 |
92 | DefaultHostResolver = SingleHostResolver
93 |
94 |
95 | def get_resolver(
96 | strategy: str,
97 | host_count: int,
98 | max_tries: Optional[int] = None,
99 | ) -> HostResolver:
100 | """Return a host resolver based on the strategy.
101 |
102 | Args:
103 | strategy (str): Resolver strategy.
104 | host_count (int): Number of hosts.
105 | max_tries (int): Maximum number of attempts to try a host.
106 |
107 | Returns:
108 | HostResolver: Host resolver.
109 |
110 | Raises:
111 | ValueError: If the strategy is not supported.
112 | """
113 | if strategy == "roundrobin":
114 | return RoundRobinHostResolver(host_count, max_tries)
115 | if strategy == "single":
116 | return SingleHostResolver(host_count, max_tries)
117 | if strategy == "default":
118 | return DefaultHostResolver(host_count, max_tries)
119 | raise ValueError(f"Unsupported host resolver strategy: {strategy}")
120 |
--------------------------------------------------------------------------------
/arangoasync/response.py:
--------------------------------------------------------------------------------
1 | __all__ = [
2 | "Response",
3 | ]
4 |
5 | from typing import Optional
6 |
7 | from arangoasync.request import Method
8 | from arangoasync.typings import ResponseHeaders
9 |
10 |
11 | class Response:
12 | """HTTP response.
13 |
14 | Parameters:
15 | method (Method): HTTP method.
16 | url (str): API URL.
17 | headers (dict): Response headers.
18 | status_code (int): Response status code.
19 | status_text (str): Response status text.
20 | raw_body (bytes): Raw response body.
21 |
22 | Attributes:
23 | method (Method): HTTP method.
24 | url (str): API URL.
25 | headers (dict): Response headers.
26 | status_code (int): Response status code.
27 | status_text (str): Response status text.
28 | raw_body (bytes): Raw response body.
29 | error_code (int | None): Error code from ArangoDB server.
30 | error_message (str | None): Error message from ArangoDB server.
31 | is_success (bool | None): True if response status code was 2XX.
32 | """
33 |
34 | __slots__ = (
35 | "method",
36 | "url",
37 | "headers",
38 | "status_code",
39 | "status_text",
40 | "raw_body",
41 | "error_code",
42 | "error_message",
43 | "is_success",
44 | )
45 |
46 | def __init__(
47 | self,
48 | method: Method,
49 | url: str,
50 | headers: ResponseHeaders,
51 | status_code: int,
52 | status_text: str,
53 | raw_body: bytes,
54 | ) -> None:
55 | self.method: Method = method
56 | self.url: str = url
57 | self.headers: ResponseHeaders = headers
58 | self.status_code: int = status_code
59 | self.status_text: str = status_text
60 | self.raw_body: bytes = raw_body
61 |
62 | # Populated later
63 | self.error_code: Optional[int] = None
64 | self.error_message: Optional[str] = None
65 | self.is_success: Optional[bool] = None
66 |
--------------------------------------------------------------------------------
/arangoasync/result.py:
--------------------------------------------------------------------------------
1 | __all__ = ["Result"]
2 |
3 | from typing import TypeVar, Union
4 |
5 | from arangoasync.job import AsyncJob
6 |
7 | # The Result definition has to be in a separate module because of circular imports.
8 | T = TypeVar("T")
9 | Result = Union[T, AsyncJob[T], None]
10 |
--------------------------------------------------------------------------------
/arangoasync/serialization.py:
--------------------------------------------------------------------------------
1 | __all__ = [
2 | "Serializer",
3 | "Deserializer",
4 | "JsonSerializer",
5 | "JsonDeserializer",
6 | "DefaultSerializer",
7 | "DefaultDeserializer",
8 | ]
9 |
10 | import json
11 | from abc import ABC, abstractmethod
12 | from typing import Generic, Sequence, TypeVar
13 |
14 | from arangoasync.exceptions import DeserializationError, SerializationError
15 | from arangoasync.typings import Json, Jsons
16 |
17 | T = TypeVar("T")
18 | U = TypeVar("U")
19 |
20 |
21 | class Serializer(ABC, Generic[T]): # pragma: no cover
22 | """Abstract base class for serialization.
23 |
24 | Custom serialization classes should inherit from this class.
25 | Please be mindful of the performance implications.
26 | """
27 |
28 | @abstractmethod
29 | def dumps(self, data: T | Sequence[T | str]) -> str:
30 | """Serialize any generic data.
31 |
32 | Args:
33 | data: Data to serialize.
34 |
35 | Returns:
36 | str: Serialized data.
37 |
38 | Raises:
39 | SerializationError: If the data cannot be serialized.
40 | """
41 | raise NotImplementedError
42 |
43 |
44 | class Deserializer(ABC, Generic[T, U]): # pragma: no cover
45 | """Abstract base class for deserialization.
46 |
47 | Custom deserialization classes should inherit from this class.
48 | Please be mindful of the performance implications.
49 | """
50 |
51 | @abstractmethod
52 | def loads(self, data: bytes) -> T:
53 | """Deserialize response data.
54 |
55 | Will be called on generic server data (such as server status) and
56 | single documents.
57 |
58 | Args:
59 | data (bytes): Data to deserialize.
60 |
61 | Returns:
62 | Deserialized data.
63 |
64 | Raises:
65 | DeserializationError: If the data cannot be deserialized.
66 | """
67 | raise NotImplementedError
68 |
69 | @abstractmethod
70 | def loads_many(self, data: bytes) -> U:
71 | """Deserialize response data.
72 |
73 | Will only be called when deserializing a list of documents.
74 |
75 | Args:
76 | data (bytes): Data to deserialize.
77 |
78 | Returns:
79 | Deserialized data.
80 |
81 | Raises:
82 | DeserializationError: If the data cannot be deserialized.
83 | """
84 | raise NotImplementedError
85 |
86 |
87 | class JsonSerializer(Serializer[Json]):
88 | """JSON serializer."""
89 |
90 | def dumps(self, data: Json | Sequence[str | Json]) -> str:
91 | try:
92 | return json.dumps(data, separators=(",", ":"))
93 | except Exception as e:
94 | raise SerializationError("Failed to serialize data to JSON.") from e
95 |
96 |
97 | class JsonDeserializer(Deserializer[Json, Jsons]):
98 | """JSON deserializer."""
99 |
100 | def loads(self, data: bytes) -> Json:
101 | try:
102 | return json.loads(data) # type: ignore[no-any-return]
103 | except Exception as e:
104 | raise DeserializationError("Failed to deserialize data from JSON.") from e
105 |
106 | def loads_many(self, data: bytes) -> Jsons:
107 | return self.loads(data) # type: ignore[return-value]
108 |
109 |
110 | DefaultSerializer = JsonSerializer
111 | DefaultDeserializer = JsonDeserializer
112 |
--------------------------------------------------------------------------------
/arangoasync/version.py:
--------------------------------------------------------------------------------
1 | __version__ = "0.0.4"
2 |
--------------------------------------------------------------------------------
/docs/aql.rst:
--------------------------------------------------------------------------------
1 | AQL
2 | ----
3 |
4 | **ArangoDB Query Language (AQL)** is used to read and write data. It is similar
5 | to SQL for relational databases, but without the support for data definition
6 | operations such as creating or deleting :doc:`databases `,
7 | :doc:`collections ` or :doc:`indexes `. For more
8 | information, refer to `ArangoDB Manual`_.
9 |
10 | .. _ArangoDB Manual: https://docs.arangodb.com
11 |
12 | AQL Queries
13 | ===========
14 |
15 | AQL queries are invoked from AQL wrapper. Executing queries returns
16 | :doc:`cursors `.
17 |
18 | **Example:**
19 |
20 | .. code-block:: python
21 |
22 | from arangoasync import ArangoClient, AQLQueryKillError
23 | from arangoasync.auth import Auth
24 |
25 | # Initialize the client for ArangoDB.
26 | async with ArangoClient(hosts="http://localhost:8529") as client:
27 | auth = Auth(username="root", password="passwd")
28 |
29 | # Connect to "test" database as root user.
30 | db = await client.db("test", auth=auth)
31 |
32 | # Get the API wrapper for "students" collection.
33 | students = db.collection("students")
34 |
35 | # Insert some test documents into "students" collection.
36 | await students.insert_many([
37 | {"_key": "Abby", "age": 22},
38 | {"_key": "John", "age": 18},
39 | {"_key": "Mary", "age": 21}
40 | ])
41 |
42 | # Get the AQL API wrapper.
43 | aql = db.aql
44 |
45 | # Retrieve the execution plan without running the query.
46 | plan = await aql.explain("FOR doc IN students RETURN doc")
47 |
48 | # Validate the query without executing it.
49 | validate = await aql.validate("FOR doc IN students RETURN doc")
50 |
51 | # Execute the query
52 | cursor = await db.aql.execute(
53 | "FOR doc IN students FILTER doc.age < @value RETURN doc",
54 | bind_vars={"value": 19}
55 | )
56 |
57 | # Iterate through the result cursor
58 | student_keys = []
59 | async for doc in cursor:
60 | student_keys.append(doc)
61 |
62 | # List currently running queries.
63 | queries = await aql.queries()
64 |
65 | # List any slow queries.
66 | slow_queries = await aql.slow_queries()
67 |
68 | # Clear slow AQL queries if any.
69 | await aql.clear_slow_queries()
70 |
71 | # Retrieve AQL query tracking properties.
72 | await aql.tracking()
73 |
74 | # Configure AQL query tracking properties.
75 | await aql.set_tracking(
76 | max_slow_queries=10,
77 | track_bind_vars=True,
78 | track_slow_queries=True
79 | )
80 |
81 | # Kill a running query (this should fail due to invalid ID).
82 | try:
83 | await aql.kill("some_query_id")
84 | except AQLQueryKillError as err:
85 | assert err.http_code == 404
86 |
87 | See :class:`arangoasync.aql.AQL` for API specification.
88 |
89 |
90 | AQL User Functions
91 | ==================
92 |
93 | **AQL User Functions** are custom functions you define in Javascript to extend
94 | AQL functionality. They are somewhat similar to SQL procedures.
95 |
96 | **Example:**
97 |
98 | .. code-block:: python
99 |
100 | from arangoasync import ArangoClient
101 | from arangoasync.auth import Auth
102 |
103 | # Initialize the client for ArangoDB.
104 | async with ArangoClient(hosts="http://localhost:8529") as client:
105 | auth = Auth(username="root", password="passwd")
106 |
107 | # Connect to "test" database as root user.
108 | db = await client.db("test", auth=auth)
109 |
110 | # Get the AQL API wrapper.
111 | aql = db.aql
112 |
113 | # Create a new AQL user function.
114 | await aql.create_function(
115 | # Grouping by name prefix is supported.
116 | name="functions::temperature::converter",
117 | code="function (celsius) { return celsius * 1.8 + 32; }"
118 | )
119 |
120 | # List AQL user functions.
121 | functions = await aql.functions()
122 |
123 | # Delete an existing AQL user function.
124 | await aql.delete_function("functions::temperature::converter")
125 |
126 | See :class:`arangoasync.aql.AQL` for API specification.
127 |
128 |
129 | AQL Query Cache
130 | ===============
131 |
132 | **AQL Query Cache** is used to minimize redundant calculation of the same query
133 | results. It is useful when read queries are issued frequently and write queries
134 | are not.
135 |
136 | **Example:**
137 |
138 | .. code-block:: python
139 |
140 | from arangoasync import ArangoClient
141 | from arangoasync.auth import Auth
142 |
143 | # Initialize the client for ArangoDB.
144 | async with ArangoClient(hosts="http://localhost:8529") as client:
145 | auth = Auth(username="root", password="passwd")
146 |
147 | # Connect to "test" database as root user.
148 | db = await client.db("test", auth=auth)
149 |
150 | # Get the AQL API wrapper.
151 | aql = db.aql
152 |
153 | # Retrieve AQL query cache properties.
154 | await aql.cache.properties()
155 |
156 | # Configure AQL query cache properties.
157 | await aql.cache.configure(mode="demand", max_results=10000)
158 |
159 | # List results cache entries.
160 | entries = await aql.cache.entries()
161 |
162 | # List plan cache entries.
163 | plan_entries = await aql.cache.plan_entries()
164 |
165 | # Clear results in AQL query cache.
166 | await aql.cache.clear()
167 |
168 | # Clear results in AQL query plan cache.
169 | await aql.cache.clear_plan()
170 |
171 | See :class:`arangoasync.aql.AQLQueryCache` for API specification.
172 |
--------------------------------------------------------------------------------
/docs/async.rst:
--------------------------------------------------------------------------------
1 | Async API Execution
2 | -------------------
3 |
4 | In **asynchronous API executions**, the driver sends API requests to ArangoDB in
5 | fire-and-forget style. The server processes them in the background, and
6 | the results can be retrieved once available via :class:`arangoasync.job.AsyncJob` objects.
7 |
8 | **Example:**
9 |
10 | .. code-block:: python
11 |
12 | import time
13 | from arangoasync import ArangoClient
14 | from arangoasync.auth import Auth
15 | from arangoasync.errno import HTTP_BAD_PARAMETER
16 | from arangoasync.exceptions import (
17 | AQLQueryExecuteError,
18 | AsyncJobCancelError,
19 | AsyncJobClearError,
20 | )
21 |
22 | # Initialize the client for ArangoDB.
23 | async with ArangoClient(hosts="http://localhost:8529") as client:
24 | auth = Auth(username="root", password="passwd")
25 |
26 | # Connect to "test" database as root user.
27 | db = await client.db("test", auth=auth)
28 |
29 | # Begin async execution. This returns an instance of AsyncDatabase, a
30 | # database-level API wrapper tailored specifically for async execution.
31 | async_db = db.begin_async_execution(return_result=True)
32 |
33 | # Child wrappers are also tailored for async execution.
34 | async_aql = async_db.aql
35 | async_col = async_db.collection("students")
36 |
37 | # API execution context is always set to "async".
38 | assert async_db.context == "async"
39 | assert async_aql.context == "async"
40 | assert async_col.context == "async"
41 |
42 | # On API execution, AsyncJob objects are returned instead of results.
43 | job1 = await async_col.insert({"_key": "Neal"})
44 | job2 = await async_col.insert({"_key": "Lily"})
45 | job3 = await async_aql.execute("RETURN 100000")
46 | job4 = await async_aql.execute("INVALID QUERY") # Fails due to syntax error.
47 |
48 | # Retrieve the status of each async job.
49 | for job in [job1, job2, job3, job4]:
50 | # Job status can be "pending" or "done".
51 | assert await job.status() in {"pending", "done"}
52 |
53 | # Let's wait until the jobs are finished.
54 | while await job.status() != "done":
55 | time.sleep(0.1)
56 |
57 | # Retrieve the results of successful jobs.
58 | metadata = await job1.result()
59 | assert metadata["_id"] == "students/Neal"
60 |
61 | metadata = await job2.result()
62 | assert metadata["_id"] == "students/Lily"
63 |
64 | cursor = await job3.result()
65 | assert await cursor.next() == 100000
66 |
67 | # If a job fails, the exception is propagated up during result retrieval.
68 | try:
69 | result = await job4.result()
70 | except AQLQueryExecuteError as err:
71 | assert err.http_code == HTTP_BAD_PARAMETER
72 |
73 | # Cancel a job. Only pending jobs still in queue may be cancelled.
74 | # Since job3 is done, there is nothing to cancel and an exception is raised.
75 | try:
76 | await job3.cancel()
77 | except AsyncJobCancelError as err:
78 | print(err.message)
79 |
80 | # Clear the result of a job from ArangoDB server to free up resources.
81 | # Result of job4 was removed from the server automatically upon retrieval,
82 | # so attempt to clear it raises an exception.
83 | try:
84 | await job4.clear()
85 | except AsyncJobClearError as err:
86 | print(err.message)
87 |
88 | # List the IDs of the first 100 async jobs completed.
89 | jobs_done = await db.async_jobs(status="done", count=100)
90 |
91 | # List the IDs of the first 100 async jobs still pending.
92 | jobs_pending = await db.async_jobs(status='pending', count=100)
93 |
94 | # Clear all async jobs still sitting on the server.
95 | await db.clear_async_jobs()
96 |
97 | Cursors returned from async API wrappers will no longer send async requests when they fetch more results, but behave
98 | like regular cursors instead. This makes sense, because the point of cursors is iteration, whereas async jobs are meant
99 | for one-shot requests. However, the first result retrieval is still async, and only then the cursor is returned, making
100 | async AQL requests effective for queries with a long execution time.
101 |
102 | **Example:**
103 |
104 | .. code-block:: python
105 |
106 | from arangoasync import ArangoClient
107 | from arangoasync.auth import Auth
108 |
109 | # Initialize the client for ArangoDB.
110 | async with ArangoClient(hosts="http://localhost:8529") as client:
111 | auth = Auth(username="root", password="passwd")
112 |
113 | # Connect to "test" database as root user.
114 | db = await client.db("test", auth=auth)
115 |
116 | # Get the API wrapper for "students" collection.
117 | students = db.collection("students")
118 |
119 | # Insert some documents into the collection.
120 | await students.insert_many([{"_key": "Neal"}, {"_key": "Lily"}])
121 |
122 | # Begin async execution.
123 | async_db = db.begin_async_execution(return_result=True)
124 |
125 | aql = async_db.aql
126 | job = await aql.execute(
127 | f"FOR d IN {students.name} SORT d._key RETURN d",
128 | count=True,
129 | batch_size=1,
130 | ttl=1000,
131 | )
132 | await job.wait()
133 |
134 | # Iterate through the cursor.
135 | # Although the request to fetch the cursor is async, its underlying executor is no longer async.
136 | # Next batches will be fetched in real-time.
137 | doc_cnt = 0
138 | cursor = await job.result()
139 | async with cursor as ctx:
140 | async for _ in ctx:
141 | doc_cnt += 1
142 | assert doc_cnt == 2
143 |
144 | .. note::
145 | Be mindful of server-side memory capacity when issuing a large number of
146 | async requests in small time interval.
147 |
148 | See :class:`arangoasync.database.AsyncDatabase` and :class:`arangoasync.job.AsyncJob` for API specification.
149 |
--------------------------------------------------------------------------------
/docs/authentication.rst:
--------------------------------------------------------------------------------
1 | Authentication
2 | --------------
3 |
4 | Two HTTP authentication methods are supported out of the box:
5 |
6 | 1. Basic username and password authentication
7 | 2. JSON Web Tokens (JWT)
8 |
9 | Basic Authentication
10 | ====================
11 |
12 | This is the default authentication method.
13 |
14 | **Example:**
15 |
16 | .. code-block:: python
17 |
18 | from arangoasync import ArangoClient
19 | from arangoasync.auth import Auth
20 |
21 | # Initialize the client for ArangoDB.
22 | async with ArangoClient(hosts="http://localhost:8529") as client:
23 | auth = Auth(
24 | username="root",
25 | password="passwd",
26 | encoding="utf-8" # Encoding for the password, default is utf-8.
27 | )
28 |
29 | # Connect to "test" database as root user.
30 | db = await client.db(
31 | "test", # database name
32 | auth_method="basic", # use basic authentication (default)
33 | auth=auth, # authentication details
34 | verify=True, # verify the connection (optional)
35 | )
36 |
37 | JSON Web Tokens (JWT)
38 | =====================
39 |
40 | You can obtain the JWT token from the use server using username and password.
41 | Upon expiration, the token gets refreshed automatically and requests are retried.
42 | The client and server clocks must be synchronized for the automatic refresh
43 | to work correctly.
44 |
45 | **Example:**
46 |
47 | .. code-block:: python
48 |
49 | from arangoasync import ArangoClient
50 | from arangoasync.auth import Auth
51 |
52 | # Initialize the client for ArangoDB.
53 | async with ArangoClient(hosts="http://localhost:8529") as client:
54 | auth = Auth(username="root", password="passwd")
55 |
56 | # Successful authentication with auth only
57 | db = await client.db(
58 | "test",
59 | auth_method="jwt",
60 | auth=auth,
61 | verify=True,
62 | )
63 |
64 | # Now you have the token on hand.
65 | token = db.connection.token
66 |
67 | # You can use the token directly.
68 | db = await client.db("test", auth_method="jwt", token=token, verify=True)
69 |
70 | # In order to allow the token to be automatically refreshed, you should use both auth and token.
71 | db = await client.db(
72 | "test",
73 | auth_method="jwt",
74 | auth=auth,
75 | token=token,
76 | verify=True,
77 | )
78 |
79 | # Force a token refresh.
80 | await db.connection.refresh_token()
81 | new_token = db.connection.token
82 |
83 | # Log in with the first token.
84 | db2 = await client.db(
85 | "test",
86 | auth_method="jwt",
87 | token=token,
88 | verify=True,
89 | )
90 |
91 | # You can manually set tokens.
92 | db2.connection.token = new_token
93 | await db2.connection.ping()
94 |
95 |
96 | If you configured a superuser token, you don't need to provide any credentials.
97 |
98 | **Example:**
99 |
100 | .. code-block:: python
101 |
102 | from arangoasync import ArangoClient
103 | from arangoasync.auth import JwtToken
104 |
105 | # Initialize the client for ArangoDB.
106 | async with ArangoClient(hosts="http://localhost:8529") as client:
107 |
108 | # Generate a JWT token for authentication. You must know the "secret".
109 | token = JwtToken.generate_token("secret")
110 |
111 | # Superuser authentication, no need for the auth parameter.
112 | db = await client.db(
113 | "test",
114 | auth_method="superuser",
115 | token=token,
116 | verify=True
117 | )
118 |
--------------------------------------------------------------------------------
/docs/certificates.rst:
--------------------------------------------------------------------------------
1 | TLS
2 | ---
3 |
4 | When you need fine-grained control over TLS settings, you build a Python
5 | :class:`ssl.SSLContext` and hand it to the :class:`arangoasync.http.DefaultHTTPClient` class.
6 | Here are the most common patterns.
7 |
8 |
9 | Basic client-side HTTPS with default settings
10 | =============================================
11 |
12 | Create a “secure by default” client context. This will verify server certificates against your
13 | OS trust store and check hostnames.
14 |
15 | **Example:**
16 |
17 | .. code-block:: python
18 |
19 | from arangoasync import ArangoClient
20 | from arangoasync.auth import Auth
21 | from arangoasync.http import DefaultHTTPClient
22 | import ssl
23 |
24 | # Create a default client context.
25 | ssl_ctx = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH)
26 | http_client = DefaultHTTPClient(ssl_context=ssl_ctx)
27 |
28 | # Initialize the client for ArangoDB.
29 | client = ArangoClient(
30 | hosts="https://localhost:8529",
31 | http_client=http_client,
32 | )
33 |
34 | Custom CA bundle
35 | ================
36 |
37 | If you have a custom CA file, this allows you to trust the private CA.
38 |
39 | **Example:**
40 |
41 | .. code-block:: python
42 |
43 | from arangoasync import ArangoClient
44 | from arangoasync.auth import Auth
45 | from arangoasync.http import DefaultHTTPClient
46 | import ssl
47 |
48 | # Use a custom CA bundle.
49 | ssl_ctx = ssl.create_default_context(cafile="path/to/ca.pem")
50 | http_client = DefaultHTTPClient(ssl_context=ssl_ctx)
51 |
52 | # Initialize the client for ArangoDB.
53 | client = ArangoClient(
54 | hosts="https://localhost:8529",
55 | http_client=http_client,
56 | )
57 |
58 | Disabling certificate verification
59 | ==================================
60 |
61 | If you want to disable *all* certification checks (not recommended), create an unverified
62 | context.
63 |
64 | **Example:**
65 |
66 | .. code-block:: python
67 |
68 | from arangoasync import ArangoClient
69 | from arangoasync.auth import Auth
70 | from arangoasync.http import DefaultHTTPClient
71 | import ssl
72 |
73 | # Disable certificate verification.
74 | ssl_ctx = ssl._create_unverified_context()
75 | http_client = DefaultHTTPClient(ssl_context=ssl_ctx)
76 |
77 | # Initialize the client for ArangoDB.
78 | client = ArangoClient(
79 | hosts="https://localhost:8529",
80 | http_client=http_client,
81 | )
82 |
83 | Use a client certificate chain
84 | ==============================
85 |
86 | **Example:**
87 |
88 | .. code-block:: python
89 |
90 | from arangoasync import ArangoClient
91 | from arangoasync.auth import Auth
92 | from arangoasync.http import DefaultHTTPClient
93 | import ssl
94 |
95 | # Load a certificate chain.
96 | ssl_ctx = ssl.create_default_context(cafile="path/to/ca.pem")
97 | ssl_ctx.load_cert_chain(certfile="path/to/cert.pem", keyfile="path/to/key.pem")
98 | http_client = DefaultHTTPClient(ssl_context=ssl_ctx)
99 |
100 | # Initialize the client for ArangoDB.
101 | client = ArangoClient(
102 | hosts="https://localhost:8529",
103 | http_client=http_client,
104 | )
105 |
106 | .. note::
107 | For best performance, re-use one SSLContext across many requests/sessions to amortize handshake cost.
108 |
109 | If you want to have fine-grained control over the HTTP connection, you should define
110 | your HTTP client as described in the :ref:`HTTP` section.
111 |
--------------------------------------------------------------------------------
/docs/collection.rst:
--------------------------------------------------------------------------------
1 | Collections
2 | -----------
3 |
4 | A **collection** contains :doc:`documents `. It is uniquely identified
5 | by its name which must consist only of hyphen, underscore and alphanumeric
6 | characters. There are three types of collections in python-arango:
7 |
8 | * **Standard Collection:** contains regular documents.
9 | * **Vertex Collection:** contains vertex documents for graphs. See
10 | :ref:`here ` for more details.
11 | * **Edge Collection:** contains edge documents for graphs. See
12 | :ref:`here ` for more details.
13 |
14 |
15 | Here is an example showing how you can manage standard collections:
16 |
17 | .. code-block:: python
18 |
19 | from arangoasync import ArangoClient
20 | from arangoasync.auth import Auth
21 |
22 | # Initialize the client for ArangoDB.
23 | async with ArangoClient(hosts="http://localhost:8529") as client:
24 | auth = Auth(username="root", password="passwd")
25 |
26 | # Connect to "test" database as root user.
27 | db = await client.db("test", auth=auth)
28 |
29 | # List all collections in the database.
30 | await db.collections()
31 |
32 | # Create a new collection named "students" if it does not exist.
33 | # This returns an API wrapper for "students" collection.
34 | if await db.has_collection("students"):
35 | students = db.collection("students")
36 | else:
37 | students = await db.create_collection("students")
38 |
39 | # Retrieve collection properties.
40 | name = students.name
41 | db_name = students.db_name
42 | properties = await students.properties()
43 | count = await students.count()
44 |
45 | # Perform various operations.
46 | await students.truncate()
47 |
48 | # Delete the collection.
49 | await db.delete_collection("students")
50 |
51 | See :class:`arangoasync.collection.StandardCollection` for API specification.
52 |
--------------------------------------------------------------------------------
/docs/compression.rst:
--------------------------------------------------------------------------------
1 | Compression
2 | ------------
3 |
4 | The :class:`arangoasync.client.ArangoClient` lets you define the preferred compression policy for request and responses. By default
5 | compression is disabled. You can change this by passing the `compression` parameter when creating the client. You may use
6 | :class:`arangoasync.compression.DefaultCompressionManager` or a custom subclass of :class:`arangoasync.compression.CompressionManager`.
7 |
8 | .. code-block:: python
9 |
10 | from arangoasync import ArangoClient
11 | from arangoasync.compression import DefaultCompressionManager
12 |
13 | client = ArangoClient(
14 | hosts="http://localhost:8529",
15 | compression=DefaultCompressionManager(),
16 | )
17 |
18 | Furthermore, you can customize the request compression policy by defining the minimum size of the request body that
19 | should be compressed and the desired compression level. Or, in order to explicitly disable compression, you can set the
20 | threshold parameter to -1.
21 |
22 | .. code-block:: python
23 |
24 | from arangoasync import ArangoClient
25 | from arangoasync.compression import DefaultCompressionManager
26 |
27 | # Disable request compression.
28 | client1 = ArangoClient(
29 | hosts="http://localhost:8529",
30 | compression=DefaultCompressionManager(threshold=-1),
31 | )
32 |
33 | # Enable request compression with a minimum size of 2 KB and a compression level of 8.
34 | client2 = ArangoClient(
35 | hosts="http://localhost:8529",
36 | compression=DefaultCompressionManager(threshold=2048, level=8),
37 | )
38 |
39 | You can set the `accept` parameter in order to inform the server that the client prefers compressed responses (in the form
40 | of an *Accept-Encoding* header). By default the `DefaultCompressionManager` is configured to accept responses compressed using
41 | the *deflate* algorithm. Note that the server may or may not honor this preference, depending on how it is
42 | configured. This can be controlled by setting the `--http.compress-response-threshold` option to a value greater than 0
43 | when starting the ArangoDB server.
44 |
45 | .. code-block:: python
46 |
47 | from arangoasync import ArangoClient
48 | from arangoasync.compression import AcceptEncoding, DefaultCompressionManager
49 |
50 | # Accept compressed responses explicitly.
51 | client = ArangoClient(
52 | hosts="http://localhost:8529",
53 | compression=DefaultCompressionManager(accept=AcceptEncoding.DEFLATE),
54 | )
55 |
56 | See the :class:`arangoasync.compression.CompressionManager` class for more details on how to customize the compression policy.
57 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 |
4 | # Required for autodoc
5 | sys.path.insert(0, os.path.abspath(".."))
6 |
7 | project = "python-arango-async"
8 | copyright_notice = "ArangoDB"
9 | author = "Alexandru Petenchea, Anthony Mahanna"
10 | extensions = [
11 | "sphinx_rtd_theme",
12 | "sphinx.ext.autodoc",
13 | "sphinx.ext.doctest",
14 | "sphinx.ext.viewcode",
15 | "sphinx.ext.napoleon",
16 | "sphinx.ext.intersphinx",
17 | ]
18 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
19 | html_theme = "sphinx_rtd_theme"
20 | master_doc = "index"
21 |
22 | autodoc_member_order = "bysource"
23 | autodoc_typehints = "none"
24 |
25 | intersphinx_mapping = {
26 | "python": ("https://docs.python.org/3", None),
27 | "aiohttp": ("https://docs.aiohttp.org/en/stable/", None),
28 | "jwt": ("https://pyjwt.readthedocs.io/en/stable/", None),
29 | }
30 |
31 | napoleon_google_docstring = True
32 | napoleon_numpy_docstring = False
33 | napoleon_attr_annotations = True
34 |
--------------------------------------------------------------------------------
/docs/cursor.rst:
--------------------------------------------------------------------------------
1 | Cursors
2 | -------
3 |
4 | Many operations provided by the driver (e.g. executing :doc:`aql` queries)
5 | return result **cursors** to batch the network communication between ArangoDB
6 | server and the client. Each HTTP request from a cursor fetches the
7 | next batch of results (usually documents). Depending on the query, the total
8 | number of items in the result set may or may not be known in advance.
9 |
10 | **Example:**
11 |
12 | .. code-block:: python
13 |
14 | from arangoasync import ArangoClient
15 | from arangoasync.auth import Auth
16 |
17 | # Initialize the client for ArangoDB.
18 | async with ArangoClient(hosts="http://localhost:8529") as client:
19 | auth = Auth(username="root", password="passwd")
20 |
21 | # Connect to "test" database as root user.
22 | db = await client.db("test", auth=auth)
23 |
24 | # Set up some test data to query against.
25 | await db.collection("students").insert_many([
26 | {"_key": "Abby", "age": 22},
27 | {"_key": "John", "age": 18},
28 | {"_key": "Mary", "age": 21},
29 | {"_key": "Suzy", "age": 23},
30 | {"_key": "Dave", "age": 20}
31 | ])
32 |
33 | # Execute an AQL query which returns a cursor object.
34 | cursor = await db.aql.execute(
35 | "FOR doc IN students FILTER doc.age > @val RETURN doc",
36 | bind_vars={"val": 17},
37 | batch_size=2,
38 | count=True
39 | )
40 |
41 | # Get the cursor ID.
42 | cid = cursor.id
43 |
44 | # Get the items in the current batch.
45 | batch = cursor.batch
46 |
47 | # Check if the current batch is empty.
48 | is_empty = cursor.empty()
49 |
50 | # Get the total count of the result set.
51 | cnt = cursor.count
52 |
53 | # Flag indicating if there are more to be fetched from server.
54 | has_more = cursor.has_more
55 |
56 | # Flag indicating if the results are cached.
57 | is_cached = cursor.cached
58 |
59 | # Get the cursor statistics.
60 | stats = cursor.statistics
61 |
62 | # Get the performance profile.
63 | profile = cursor.profile
64 |
65 | # Get any warnings produced from the query.
66 | warnings = cursor.warnings
67 |
68 | # Return the next item from the cursor. If current batch is depleted, the
69 | # next batch is fetched from the server automatically.
70 | await cursor.next()
71 |
72 | # Return the next item from the cursor. If current batch is depleted, an
73 | # exception is thrown. You need to fetch the next batch manually.
74 | cursor.pop()
75 |
76 | # Fetch the next batch and add them to the cursor object.
77 | await cursor.fetch()
78 |
79 | # Delete the cursor from the server.
80 | await cursor.close()
81 |
82 | See :class:`arangoasync.cursor.Cursor` for API specification.
83 |
84 | Cursors can be used together with a context manager to ensure that the resources get freed up
85 | when the cursor is no longer needed. Asynchronous iteration is also supported, allowing you to
86 | iterate over the cursor results without blocking the event loop.
87 |
88 | **Example:**
89 |
90 | .. code-block:: python
91 |
92 | from arangoasync import ArangoClient
93 | from arangoasync.auth import Auth
94 | from arangoasync.exceptions import CursorCloseError
95 |
96 | # Initialize the client for ArangoDB.
97 | async with ArangoClient(hosts="http://localhost:8529") as client:
98 | auth = Auth(username="root", password="passwd")
99 |
100 | # Connect to "test" database as root user.
101 | db = await client.db("test", auth=auth)
102 |
103 | # Set up some test data to query against.
104 | await db.collection("students").insert_many([
105 | {"_key": "Abby", "age": 22},
106 | {"_key": "John", "age": 18},
107 | {"_key": "Mary", "age": 21},
108 | {"_key": "Suzy", "age": 23},
109 | {"_key": "Dave", "age": 20}
110 | ])
111 |
112 | # Execute an AQL query which returns a cursor object.
113 | cursor = await db.aql.execute(
114 | "FOR doc IN students FILTER doc.age > @val RETURN doc",
115 | bind_vars={"val": 17},
116 | batch_size=2,
117 | count=True
118 | )
119 |
120 | # Iterate over the cursor in an async context manager.
121 | async with cursor as ctx:
122 | async for student in ctx:
123 | print(student)
124 |
125 | # The cursor is automatically closed when exiting the context manager.
126 | try:
127 | await cursor.close()
128 | except CursorCloseError:
129 | print(f"Cursor already closed!")
130 |
131 | If the fetched result batch is depleted while you are iterating over a cursor
132 | (or while calling the method :func:`arangoasync.cursor.Cursor.next`), the driver
133 | automatically sends an HTTP request to the server in order to fetch the next batch
134 | (just-in-time style). To control exactly when the fetches occur, you can use
135 | methods like :func:`arangoasync.cursor.Cursor.fetch` and :func:`arangoasync.cursor.Cursor.pop`
136 | instead.
137 |
138 | **Example:**
139 |
140 | .. code-block:: python
141 |
142 | from arangoasync import ArangoClient
143 | from arangoasync.auth import Auth
144 |
145 | # Initialize the client for ArangoDB.
146 | async with ArangoClient(hosts="http://localhost:8529") as client:
147 | auth = Auth(username="root", password="passwd")
148 |
149 | # Connect to "test" database as root user.
150 | db = await client.db("test", auth=auth)
151 |
152 | # Set up some test data to query against.
153 | await db.collection("students").insert_many([
154 | {"_key": "Abby", "age": 22},
155 | {"_key": "John", "age": 18},
156 | {"_key": "Mary", "age": 21}
157 | ])
158 |
159 | # You can manually fetch and pop for finer control.
160 | cursor = await db.aql.execute("FOR doc IN students RETURN doc", batch_size=1)
161 | while cursor.has_more: # Fetch until nothing is left on the server.
162 | await cursor.fetch()
163 | while not cursor.empty(): # Pop until nothing is left on the cursor.
164 | student = cursor.pop()
165 | print(student)
166 |
167 | You can use the `allow_retry` parameter of :func:`arangoasync.aql.AQL.execute`
168 | to automatically retry the request if the cursor encountered any issues during
169 | the previous fetch operation. Note that this feature causes the server to
170 | cache the last batch. To allow re-fetching of the very last batch of the query,
171 | the server cannot automatically delete the cursor. Once you have successfully
172 | received the last batch, you should call :func:`arangoasync.cursor.Cursor.close`,
173 | or use a context manager to ensure the cursor is closed properly.
174 |
175 | **Example:**
176 |
177 | .. code-block:: python
178 |
179 | from arangoasync import ArangoClient
180 | from arangoasync.auth import Auth
181 | from arangoasync.typings import QueryProperties
182 |
183 | # Initialize the client for ArangoDB.
184 | async with ArangoClient(hosts="http://localhost:8529") as client:
185 | auth = Auth(username="root", password="passwd")
186 |
187 | # Connect to "test" database as root user.
188 | db = await client.db("test", auth=auth)
189 |
190 | # Set up some test data to query against.
191 | await db.collection("students").insert_many([
192 | {"_key": "Abby", "age": 22},
193 | {"_key": "John", "age": 18},
194 | {"_key": "Mary", "age": 21}
195 | ])
196 |
197 | cursor = await db.aql.execute(
198 | "FOR doc IN students RETURN doc",
199 | batch_size=1,
200 | options=QueryProperties(allow_retry=True)
201 | )
202 |
203 | while cursor.has_more:
204 | try:
205 | await cursor.fetch()
206 | except ConnectionError:
207 | # Retry the request.
208 | continue
209 |
210 | while not cursor.empty():
211 | student = cursor.pop()
212 | print(student)
213 |
214 | # Delete the cursor from the server.
215 | await cursor.close()
216 |
217 | For more information about various query properties, see :class:`arangoasync.typings.QueryProperties`.
218 |
--------------------------------------------------------------------------------
/docs/database.rst:
--------------------------------------------------------------------------------
1 | Databases
2 | ---------
3 |
4 | ArangoDB server can have an arbitrary number of **databases**. Each database
5 | has its own set of :doc:`collections ` and graphs.
6 | There is a special database named ``_system``, which cannot be dropped and
7 | provides operations for managing users, permissions and other databases. Most
8 | of the operations can only be executed by admin users. See :doc:`user` for more
9 | information.
10 |
11 | **Example:**
12 |
13 | .. code-block:: python
14 |
15 | from arangoasync import ArangoClient
16 | from arangoasync.auth import Auth
17 |
18 | # Initialize the client for ArangoDB.
19 | async with ArangoClient(hosts="http://localhost:8529") as client:
20 | auth = Auth(username="root", password="passwd")
21 |
22 | # Connect to "_system" database as root user.
23 | sys_db = await client.db("_system", auth=auth)
24 |
25 | # List all databases.
26 | await sys_db.databases()
27 |
28 | # Create a new database named "test" if it does not exist.
29 | # Only root user has access to it at time of its creation.
30 | if not await sys_db.has_database("test"):
31 | await sys_db.create_database("test")
32 |
33 | # Delete the database.
34 | await sys_db.delete_database("test")
35 |
36 | # Create a new database named "test" along with a new set of users.
37 | # Only "jane", "john", "jake" and root user have access to it.
38 | if not await sys_db.has_database("test"):
39 | await sys_db.create_database(
40 | name="test",
41 | users=[
42 | {"username": "jane", "password": "foo", "active": True},
43 | {"username": "john", "password": "bar", "active": True},
44 | {"username": "jake", "password": "baz", "active": True},
45 | ],
46 | )
47 |
48 | # Connect to the new "test" database as user "jane".
49 | db = await client.db("test", auth=Auth("jane", "foo"))
50 |
51 | # Make sure that user "jane" has read and write permissions.
52 | await sys_db.update_permission(username="jane", permission="rw", database="test")
53 |
54 | # Retrieve various database and server information.
55 | name = db.name
56 | version = await db.version()
57 | status = await db.status()
58 | collections = await db.collections()
59 |
60 | # Delete the database. Note that the new users will remain.
61 | await sys_db.delete_database("test")
62 |
63 | See :class:`arangoasync.client.ArangoClient` and :class:`arangoasync.database.StandardDatabase` for API specification.
64 |
--------------------------------------------------------------------------------
/docs/document.rst:
--------------------------------------------------------------------------------
1 | Documents
2 | ---------
3 |
4 | In python-arango-async, a **document** is an object with the following
5 | properties:
6 |
7 | * Is JSON serializable.
8 | * May be nested to an arbitrary depth.
9 | * May contain lists.
10 | * Contains the ``_key`` field, which identifies the document uniquely within a
11 | specific collection.
12 | * Contains the ``_id`` field (also called the *handle*), which identifies the
13 | document uniquely across all collections within a database. This ID is a
14 | combination of the collection name and the document key using the format
15 | ``{collection}/{key}`` (see example below).
16 | * Contains the ``_rev`` field. ArangoDB supports MVCC (Multiple Version
17 | Concurrency Control) and is capable of storing each document in multiple
18 | revisions. Latest revision of a document is indicated by this field. The
19 | field is populated by ArangoDB and is not required as input unless you want
20 | to validate a document against its current revision.
21 |
22 | For more information on documents and associated terminologies, refer to
23 | `ArangoDB Manual`_. Here is an example of a valid document in "students"
24 | collection:
25 |
26 | .. _ArangoDB Manual: https://docs.arangodb.com
27 |
28 | .. code-block:: json
29 |
30 | {
31 | "_id": "students/bruce",
32 | "_key": "bruce",
33 | "_rev": "_Wm3dzEi--_",
34 | "first_name": "Bruce",
35 | "last_name": "Wayne",
36 | "address": {
37 | "street" : "1007 Mountain Dr.",
38 | "city": "Gotham",
39 | "state": "NJ"
40 | },
41 | "is_rich": true,
42 | "friends": ["robin", "gordon"]
43 | }
44 |
45 | .. _edge-documents:
46 |
47 | **Edge documents (edges)** are similar to standard documents but with two
48 | additional required fields ``_from`` and ``_to``. Values of these fields must
49 | be the handles of "from" and "to" vertex documents linked by the edge document
50 | in question (see :doc:`graph` for details). Edge documents are contained in
51 | :ref:`edge collections `. Here is an example of a valid edge
52 | document in "friends" edge collection:
53 |
54 | .. code-block:: python
55 |
56 | {
57 | "_id": "friends/001",
58 | "_key": "001",
59 | "_rev": "_Wm3d4le--_",
60 | "_fro"': "students/john",
61 | "_to": "students/jane",
62 | "closeness": 9.5
63 | }
64 |
65 | Standard documents are managed via collection API wrapper:
66 |
67 | .. code-block:: python
68 |
69 | from arangoasync import ArangoClient
70 | from arangoasync.auth import Auth
71 |
72 | # Initialize the client for ArangoDB.
73 | async with ArangoClient(hosts="http://localhost:8529") as client:
74 | auth = Auth(username="root", password="passwd")
75 |
76 | # Connect to "test" database as root user.
77 | db = await client.db("test", auth=auth)
78 |
79 | # Get the API wrapper for "students" collection.
80 | students = db.collection("students")
81 |
82 | # Create some test documents to play around with.
83 | lola = {"_key": "lola", "GPA": 3.5, "first": "Lola", "last": "Martin"}
84 | abby = {"_key": "abby", "GPA": 3.2, "first": "Abby", "last": "Page"}
85 | john = {"_key": "john", "GPA": 3.6, "first": "John", "last": "Kim"}
86 | emma = {"_key": "emma", "GPA": 4.0, "first": "Emma", "last": "Park"}
87 |
88 | # Insert a new document. This returns the document metadata.
89 | metadata = await students.insert(lola)
90 | assert metadata["_id"] == "students/lola"
91 | assert metadata["_key"] == "lola"
92 |
93 | # Insert multiple documents.
94 | await students.insert_many([abby, john, emma])
95 |
96 | # Check if documents exist in the collection.
97 | assert await students.has("lola")
98 |
99 | # Retrieve the total document count.
100 | count = await students.count()
101 |
102 | # Retrieve one or more matching documents.
103 | async for student in await students.find({"first": "John"}):
104 | assert student["_key"] == "john"
105 | assert student["GPA"] == 3.6
106 | assert student["last"] == "Kim"
107 |
108 | # Retrieve one or more matching documents, sorted by a field.
109 | async for student in await students.find({"first": "John"}, sort=[{"sort_by": "GPA", "sort_order": "DESC"}]):
110 | assert student["_key"] == "john"
111 | assert student["GPA"] == 3.6
112 | assert student["last"] == "Kim"
113 |
114 | # Retrieve a document by key.
115 | await students.get("john")
116 |
117 | # Retrieve a document by ID.
118 | await students.get("students/john")
119 |
120 | # Retrieve a document by body with "_id" field.
121 | await students.get({"_id": "students/john"})
122 |
123 | # Retrieve a document by body with "_key" field.
124 | await students.get({"_key": "john"})
125 |
126 | # Retrieve multiple documents by ID, key or body.
127 | await students.get_many(["abby", "students/lola", {"_key": "john"}])
128 |
129 | # Update a single document.
130 | lola["GPA"] = 2.6
131 | await students.update(lola)
132 |
133 | # Update one or more matching documents.
134 | await students.update_match({"last": "Park"}, {"GPA": 3.0})
135 |
136 | # Replace a single document.
137 | emma["GPA"] = 3.1
138 | await students.replace(emma)
139 |
140 | # Replace one or more matching documents.
141 | becky = {"first": "Becky", "last": "Solis", "GPA": "3.3"}
142 | await students.replace_match({"first": "Emma"}, becky)
143 |
144 | # Delete a document by body with "_id" or "_key" field.
145 | await students.delete(emma)
146 |
147 | # Delete multiple documents. Missing ones are ignored.
148 | await students.delete_many([abby, emma])
149 |
150 | # Delete one or more matching documents.
151 | await students.delete_match({"first": "Emma"})
152 |
153 | See :class:`arangoasync.database.StandardDatabase` and :class:`arangoasync.collection.StandardCollection` for API specification.
154 |
--------------------------------------------------------------------------------
/docs/errno.rst:
--------------------------------------------------------------------------------
1 | Error Codes
2 | -----------
3 |
4 | ArangoDB error code constants are provided for convenience.
5 |
6 | **Example**
7 |
8 | .. code-block:: python
9 |
10 | from arangoasync import errno
11 |
12 | # Some examples
13 | assert errno.NOT_IMPLEMENTED == 9
14 | assert errno.DOCUMENT_REV_BAD == 1239
15 | assert errno.DOCUMENT_NOT_FOUND == 1202
16 |
17 | You can see the full list of error codes in the `errno.py`_ file.
18 |
19 | For more information, refer to the `ArangoDB Manual`_.
20 |
21 | .. _ArangoDB Manual: https://www.arangodb.com/docs/stable/appendix-error-codes.html
22 | .. _errno.py: https://github.com/arangodb/python-arango-async/blob/main/arangoasync/errno.py
23 |
--------------------------------------------------------------------------------
/docs/errors.rst:
--------------------------------------------------------------------------------
1 | Error Handling
2 | --------------
3 |
4 | All python-arango exceptions inherit :class:`arangoasync.exceptions.ArangoError`,
5 | which splits into subclasses :class:`arangoasync.exceptions.ArangoServerError` and
6 | :class:`arangoasync.exceptions.ArangoClientError`.
7 |
8 | **Example**
9 |
10 | .. code-block:: python
11 |
12 | from arangoasync.exceptions import ArangoClientError, ArangoServerError
13 |
14 | try:
15 | # Some operation that raises an error
16 | except ArangoClientError:
17 | # An error occurred on the client side
18 | except ArangoServerError:
19 | # An error occurred on the server side
20 |
21 |
22 | Server Errors
23 | =============
24 |
25 | :class:`arangoasync.exceptions.ArangoServerError` exceptions lightly wrap non-2xx
26 | HTTP responses coming from ArangoDB. Each exception object contains the error
27 | message, error code and HTTP request response details.
28 |
29 | **Example:**
30 |
31 | .. code-block:: python
32 |
33 | from arangoasync import ArangoClient, ArangoServerError, DocumentInsertError
34 | from arangoasync.auth import Auth
35 |
36 | # Initialize the client for ArangoDB.
37 | async with ArangoClient(hosts="http://localhost:8529") as client:
38 | auth = Auth(username="root", password="passwd")
39 |
40 | # Connect to "test" database as root user.
41 | db = await client.db("test", auth=auth)
42 |
43 | # Get the API wrapper for "students" collection.
44 | students = db.collection("students")
45 |
46 | try:
47 | await students.insert({"_key": "John"})
48 | await students.insert({"_key": "John"}) # duplicate key error
49 | except DocumentInsertError as err:
50 | assert isinstance(err, ArangoServerError)
51 | assert err.source == "server"
52 |
53 | msg = err.message # Exception message usually from ArangoDB
54 | err_msg = err.error_message # Raw error message from ArangoDB
55 | code = err.error_code # Error code from ArangoDB
56 | url = err.url # URL (API endpoint)
57 | method = err.http_method # HTTP method (e.g. "POST")
58 | headers = err.http_headers # Response headers
59 | http_code = err.http_code # Status code (e.g. 200)
60 |
61 | # You can inspect the ArangoDB response directly.
62 | response = err.response
63 | method = response.method # HTTP method
64 | headers = response.headers # Response headers
65 | url = response.url # Full request URL
66 | success = response.is_success # Set to True if HTTP code is 2XX
67 | raw_body = response.raw_body # Raw string response body
68 | status_txt = response.status_text # Status text (e.g "OK")
69 | status_code = response.status_code # Status code (e.g. 200)
70 | err_code = response.error_code # Error code from ArangoDB
71 |
72 | # You can also inspect the request sent to ArangoDB.
73 | request = err.request
74 | method = request.method # HTTP method
75 | endpoint = request.endpoint # API endpoint starting with "/_api"
76 | headers = request.headers # Request headers
77 | params = request.params # URL parameters
78 | data = request.data # Request payload
79 |
80 | Client Errors
81 | =============
82 |
83 | :class:`arangoasync.exceptions.ArangoClientError` exceptions originate from
84 | driver client itself. They do not contain error codes nor HTTP request
85 | response details.
86 |
87 | **Example:**
88 |
89 | .. code-block:: python
90 |
91 | from arangoasync import ArangoClient, ArangoClientError, DocumentParseError
92 | from arangoasync.auth import Auth
93 |
94 | # Initialize the client for ArangoDB.
95 | async with ArangoClient(hosts="http://localhost:8529") as client:
96 | auth = Auth(username="root", password="passwd")
97 |
98 | # Connect to "test" database as root user.
99 | db = await client.db("test", auth=auth)
100 |
101 | # Get the API wrapper for "students" collection.
102 | students = db.collection("students")
103 |
104 | try:
105 | await students.get({"_id": "invalid_id"}) # malformed document
106 | except DocumentParseError as err:
107 | assert isinstance(err, ArangoClientError)
108 | assert err.source == "client"
109 |
110 | # Only the error message is set.
111 | print(err.message)
112 |
113 | Exceptions
114 | ==========
115 |
116 | Below are all exceptions.
117 |
118 | .. automodule:: arangoasync.exceptions
119 | :members:
120 |
--------------------------------------------------------------------------------
/docs/helpers.rst:
--------------------------------------------------------------------------------
1 | .. _Helpers:
2 |
3 | Helper Types
4 | ------------
5 |
6 | The driver comes with a set of helper types and wrappers to make it easier to work with the ArangoDB API. These are
7 | designed to behave like dictionaries, but with some additional features and methods. See the :class:`arangoasync.typings.JsonWrapper` class for more details.
8 |
9 | **Example:**
10 |
11 | .. code-block:: python
12 |
13 | from arangoasync import ArangoClient
14 | from arangoasync.auth import Auth
15 | from arangoasync.typings import QueryProperties
16 |
17 | # Initialize the client for ArangoDB.
18 | async with ArangoClient(hosts="http://localhost:8529") as client:
19 | auth = Auth(username="root", password="passwd")
20 |
21 | # Connect to "test" database as root user.
22 | db = await client.db("test", auth=auth)
23 |
24 | properties = QueryProperties(
25 | allow_dirty_reads=True,
26 | allow_retry=False,
27 | fail_on_warning=True,
28 | fill_block_cache=False,
29 | full_count=True,
30 | intermediate_commit_count=1000,
31 | intermediate_commit_size=1048576,
32 | max_dnf_condition_members=10,
33 | max_nodes_per_callstack=100,
34 | max_number_of_plans=5,
35 | max_runtime=60.0,
36 | max_transaction_size=10485760,
37 | max_warning_count=10,
38 | optimizer={"rules": ["-all", "+use-indexes"]},
39 | profile=1,
40 | satellite_sync_wait=10.0,
41 | skip_inaccessible_collections=True,
42 | spill_over_threshold_memory_usage=10485760,
43 | spill_over_threshold_num_rows=100000,
44 | stream=True,
45 | use_plan_cache=True,
46 | )
47 |
48 | # The types are fully serializable.
49 | print(properties)
50 |
51 | await db.aql.execute(
52 | "FOR doc IN students RETURN doc",
53 | batch_size=1,
54 | options=properties,
55 | )
56 |
57 | You can easily customize the data representation using formatters. By default, keys are in the format used by the ArangoDB
58 | API, but you can change them to snake_case if you prefer. See :func:`arangoasync.typings.JsonWrapper.format` for more details.
59 |
60 | **Example:**
61 |
62 | .. code-block:: python
63 |
64 | from arangoasync.typings import Json, UserInfo
65 |
66 | data = {
67 | "user": "john",
68 | "password": "secret",
69 | "active": True,
70 | "extra": {"role": "admin"},
71 | }
72 | user_info = UserInfo(**data)
73 |
74 | def uppercase_formatter(data: Json) -> Json:
75 | result: Json = {}
76 | for key, value in data.items():
77 | result[key.upper()] = value
78 | return result
79 |
80 | print(user_info.format(uppercase_formatter))
81 |
82 | Helpers
83 | =======
84 |
85 | Below are all the available helpers.
86 |
87 | .. automodule:: arangoasync.typings
88 | :members:
89 |
--------------------------------------------------------------------------------
/docs/http.rst:
--------------------------------------------------------------------------------
1 | .. _HTTP:
2 |
3 | HTTP
4 | ----
5 |
6 | You can define your own HTTP client for sending requests to
7 | ArangoDB server. The default implementation uses the aiohttp_ library.
8 |
9 | Your HTTP client must inherit :class:`arangoasync.http.HTTPClient` and implement the
10 | following abstract methods:
11 |
12 | * :func:`arangoasync.http.HTTPClient.create_session`
13 | * :func:`arangoasync.http.HTTPClient.close_session`
14 | * :func:`arangoasync.http.HTTPClient.send_request`
15 |
16 | Let's take for example, the default implementation of :class:`arangoasync.http.AioHTTPClient`:
17 |
18 | * The **create_session** method returns a :class:`aiohttp.ClientSession` instance per
19 | connected host (coordinator). The session objects are stored in the client.
20 | * The **close_session** method performs the necessary cleanup for a :class:`aiohttp.ClientSession` instance.
21 | This is usually called only by the client.
22 | * The **send_request** method must uses the session to send an HTTP request, and
23 | returns a fully populated instance of :class:`arangoasync.response.Response`.
24 |
25 | **Example:**
26 |
27 | Suppose you're working on a project that uses httpx_ as a dependency and you want your
28 | own HTTP client implementation on top of :class:`httpx.AsyncClient`. Your ``HttpxHTTPClient``
29 | class might look something like this:
30 |
31 | .. code-block:: python
32 |
33 | import httpx
34 | import ssl
35 | from typing import Any, Optional
36 | from arangoasync.exceptions import ClientConnectionError
37 | from arangoasync.http import HTTPClient
38 | from arangoasync.request import Request
39 | from arangoasync.response import Response
40 |
41 | class HttpxHTTPClient(HTTPClient):
42 | """HTTP client implementation on top of httpx.AsyncClient.
43 |
44 | Args:
45 | limits (httpx.Limits | None): Connection pool limits.n
46 | timeout (httpx.Timeout | float | None): Request timeout settings.
47 | ssl_context (ssl.SSLContext | bool): SSL validation mode.
48 | `True` (default) uses httpx’s default validation (system CAs).
49 | `False` disables SSL checks.
50 | Or pass a custom `ssl.SSLContext`.
51 | """
52 |
53 | def __init__(
54 | self,
55 | limits: Optional[httpx.Limits] = None,
56 | timeout: Optional[httpx.Timeout | float] = None,
57 | ssl_context: bool | ssl.SSLContext = True,
58 | ) -> None:
59 | self._limits = limits or httpx.Limits(
60 | max_connections=100,
61 | max_keepalive_connections=20
62 | )
63 | self._timeout = timeout or httpx.Timeout(300.0, connect=60.0)
64 | if ssl_context is True:
65 | self._verify: bool | ssl.SSLContext = True
66 | elif ssl_context is False:
67 | self._verify = False
68 | else:
69 | self._verify = ssl_context
70 |
71 | def create_session(self, host: str) -> httpx.AsyncClient:
72 | return httpx.AsyncClient(
73 | base_url=host,
74 | limits=self._limits,
75 | timeout=self._timeout,
76 | verify=self._verify,
77 | )
78 |
79 | async def close_session(self, session: httpx.AsyncClient) -> None:
80 | await session.aclose()
81 |
82 | async def send_request(
83 | self,
84 | session: httpx.AsyncClient,
85 | request: Request,
86 | ) -> Response:
87 | auth: Any = None
88 | if request.auth is not None:
89 | auth = httpx.BasicAuth(
90 | username=request.auth.username,
91 | password=request.auth.password,
92 | )
93 |
94 | try:
95 | resp = await session.request(
96 | method=request.method.name,
97 | url=request.endpoint,
98 | headers=request.normalized_headers(),
99 | params=request.normalized_params(),
100 | content=request.data,
101 | auth=auth,
102 | )
103 | raw_body = resp.content
104 | return Response(
105 | method=request.method,
106 | url=str(resp.url),
107 | headers=resp.headers,
108 | status_code=resp.status_code,
109 | status_text=resp.reason_phrase,
110 | raw_body=raw_body,
111 | )
112 | except httpx.HTTPError as e:
113 | raise ClientConnectionError(str(e)) from e
114 |
115 | Then you would inject your client as follows:
116 |
117 | .. code-block:: python
118 |
119 | from arangoasync import ArangoClient
120 | from arangoasync.auth import Auth
121 |
122 | # Initialize the client for ArangoDB.
123 | async with ArangoClient(
124 | hosts="http://localhost:8529",
125 | http_client=HttpxHTTPClient(),
126 | ) as client:
127 | auth = Auth(username="root", password="passwd")
128 |
129 | # Connect to "test" database as root user.
130 | db = await client.db("test", auth=auth, verify=True)
131 |
132 | # List all collections.
133 | cols = await db.collections()
134 |
135 | .. _aiohttp: https://docs.aiohttp.org/en/stable/
136 | .. _httpx: https://www.python-httpx.org/
137 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | .. image:: /static/logo.png
2 |
3 | |
4 |
5 | python-arango-async
6 | -------------------
7 |
8 | Welcome to the documentation for python-arango-async_, a Python driver for ArangoDB_.
9 |
10 | **Note: This project is still in active development, features might be added or removed.**
11 |
12 | Requirements
13 | =============
14 |
15 | - ArangoDB version 3.11+
16 | - Python version 3.10+
17 |
18 | Installation
19 | ============
20 |
21 | .. code-block:: bash
22 |
23 | ~$ pip install python-arango-async --upgrade
24 |
25 | Contents
26 | ========
27 |
28 | **Basics**
29 |
30 | .. toctree::
31 | :maxdepth: 1
32 |
33 | overview
34 | database
35 | collection
36 | indexes
37 | document
38 | graph
39 | aql
40 |
41 | **Specialized Features**
42 |
43 | .. toctree::
44 | :maxdepth: 1
45 |
46 | transaction
47 |
48 | **API Executions**
49 |
50 | .. toctree::
51 | :maxdepth: 1
52 |
53 | async
54 |
55 | **Administration**
56 |
57 | .. toctree::
58 | :maxdepth: 1
59 |
60 | user
61 |
62 | **Miscellaneous**
63 |
64 | .. toctree::
65 | :maxdepth: 1
66 |
67 | cursor
68 | authentication
69 | http
70 | certificates
71 | compression
72 | serialization
73 | errors
74 | errno
75 | logging
76 | helpers
77 | migration
78 |
79 | **Development**
80 |
81 | .. toctree::
82 | :maxdepth: 1
83 |
84 | specs
85 |
86 | .. _ArangoDB: https://www.arangodb.com
87 | .. _python-arango-async: https://github.com/arangodb/python-arango-async
88 |
--------------------------------------------------------------------------------
/docs/indexes.rst:
--------------------------------------------------------------------------------
1 | Indexes
2 | -------
3 |
4 | **Indexes** can be added to collections to speed up document lookups. Every
5 | collection has a primary hash index on ``_key`` field by default. This index
6 | cannot be deleted or modified. Every edge collection has additional indexes
7 | on fields ``_from`` and ``_to``. For more information on indexes, refer to
8 | `ArangoDB Manual`_.
9 |
10 | .. _ArangoDB Manual: https://docs.arangodb.com
11 |
12 | **Example:**
13 |
14 | .. code-block:: python
15 |
16 | from arangoasync import ArangoClient
17 | from arangoasync.auth import Auth
18 |
19 | # Initialize the client for ArangoDB.
20 | async with ArangoClient(hosts="http://localhost:8529") as client:
21 | auth = Auth(username="root", password="passwd")
22 |
23 | # Connect to "test" database as root user.
24 | db = await client.db("test", auth=auth)
25 |
26 | # Create a new collection named "cities".
27 | cities = await db.create_collection("cities")
28 |
29 | # List the indexes in the collection.
30 | indexes = await cities.indexes()
31 |
32 | # Add a new persistent index on document fields "continent" and "country".
33 | # Indexes may be added with a name that can be referred to in AQL queries.
34 | persistent_index = await cities.add_index(
35 | type="persistent",
36 | fields=['continent', 'country'],
37 | options={"unique": True, "name": "continent_country_index"}
38 | )
39 |
40 | # Add new fulltext indexes on fields "continent" and "country".
41 | index = await cities.add_index(type="fulltext", fields=["continent"])
42 | index = await cities.add_index(type="fulltext", fields=["country"])
43 |
44 | # Add a new geo-spatial index on field 'coordinates'.
45 | index = await cities.add_index(type="geo", fields=["coordinates"])
46 |
47 | # Add a new TTL (time-to-live) index on field 'currency'.
48 | index = await cities.add_index(type="ttl", fields=["currency"], options={"expireAfter": 200})
49 |
50 | # Delete the last index from the collection.
51 | await cities.delete_index(index["id"])
52 |
53 | See :class:`arangoasync.collection.StandardCollection` for API specification.
54 |
--------------------------------------------------------------------------------
/docs/logging.rst:
--------------------------------------------------------------------------------
1 | Logging
2 | -------
3 |
4 | If if helps to debug your application, you can enable logging to see all the requests sent by the driver to the ArangoDB server.
5 |
6 | .. code-block:: python
7 |
8 | import logging
9 | from arangoasync import ArangoClient
10 | from arangoasync.auth import Auth
11 | from arangoasync.logger import logger
12 |
13 | # Set up logging
14 | logging.basicConfig(level=logging.DEBUG)
15 | logger.setLevel(level=logging.DEBUG)
16 |
17 | # Initialize the client for ArangoDB.
18 | async with ArangoClient(hosts="http://localhost:8529") as client:
19 | auth = Auth(username="root", password="passwd")
20 |
21 | # Connect to "test" database as root user.
22 | db = await client.db("test", auth=auth)
23 |
24 | # Get the API wrapper for "students" collection.
25 | students = db.collection("students")
26 |
27 | # Insert a document into the collection.
28 | await students.insert({"name": "John Doe", "age": 25})
29 |
30 | The insert generates a log message similar to: `DEBUG:arangoasync:Sending request to host 0 (0): `.
31 |
--------------------------------------------------------------------------------
/docs/migration.rst:
--------------------------------------------------------------------------------
1 | Coming from python-arango
2 | -------------------------
3 |
4 | Generally, migrating from `python-arango`_ should be a smooth transition. For the most part, the API is similar,
5 | but there are a few things to note._
6 |
7 | Helpers
8 | =======
9 |
10 | The current driver comes with :ref:`Helpers`, because we want to:
11 |
12 | 1. Facilitate better type hinting and auto-completion in IDEs.
13 | 2. Ensure an easier 1-to-1 mapping of the ArangoDB API.
14 |
15 | For example, coming from the synchronous driver, creating a new user looks like this:
16 |
17 | .. code-block:: python
18 |
19 | sys_db.create_user(
20 | username="johndoe@gmail.com",
21 | password="first_password",
22 | active=True,
23 | extra={"team": "backend", "title": "engineer"}
24 | )
25 |
26 | In the asynchronous driver, it looks like this:
27 |
28 | .. code-block:: python
29 |
30 | from arangoasync.typings import UserInfo
31 |
32 | user_info = UserInfo(
33 | username="johndoe@gmail.com",
34 | password="first_password",
35 | active=True,
36 | extra={"team": "backend", "title": "engineer"}
37 | )
38 | await sys_db.create_user(user_info)
39 |
40 | CamelCase vs. snake_case
41 | ========================
42 |
43 | Upon returning results, for the most part, the synchronous driver mostly tries to stick to snake case. Unfortunately,
44 | this is not always consistent.
45 |
46 | .. code-block:: python
47 |
48 | status = db.status()
49 | assert "host" in status
50 | assert "operation_mode" in status
51 |
52 | The asynchronous driver, however, tries to stick to a simple rule:
53 |
54 | * If the API returns a camel case key, it will be returned as is.
55 | * Parameters passed from client to server use the snake case equivalent of the camel case keys required by the API
56 | (e.g. `userName` becomes `user_name`). This is done to ensure PEP8 compatibility.
57 |
58 | .. code-block:: python
59 |
60 | from arangoasync.typings import ServerStatusInformation
61 |
62 | status: ServerStatusInformation = await db.status()
63 | assert "host" in status
64 | assert "operationMode" in status
65 | print(status.host)
66 | print(status.operation_mode)
67 |
68 | You can use the :func:`arangoasync.typings.JsonWrapper.format` method to gain more control over the formatting of
69 | keys.
70 |
71 | Serialization
72 | =============
73 |
74 | Check out the :ref:`Serialization` section to learn more about how to implement your own serializer/deserializer. The
75 | current driver makes use of generic types and allows for a higher degree of customization.
76 |
77 | Mixing sync and async
78 | =====================
79 |
80 | Sometimes you may need to mix the two. This is not recommended, but it takes time to migrate everything. If you need to
81 | do this, you can use the :func:`asyncio.to_thread` function to run a synchronous function in separate thread, without
82 | compromising the async event loop.
83 |
84 | .. code-block:: python
85 |
86 | # Use a python-arango synchronous client
87 | sys_db = await asyncio.to_thread(
88 | client.db,
89 | "_system",
90 | username="root",
91 | password="passwd"
92 | )
93 |
94 | .. _python-arango: https://docs.python-arango.com
95 |
--------------------------------------------------------------------------------
/docs/overview.rst:
--------------------------------------------------------------------------------
1 | Getting Started
2 | ---------------
3 |
4 | Here is an example showing how **python-arango-async** client can be used:
5 |
6 | .. code-block:: python
7 |
8 | from arangoasync import ArangoClient
9 | from arangoasync.auth import Auth
10 |
11 | # Initialize the client for ArangoDB.
12 | async with ArangoClient(hosts="http://localhost:8529") as client:
13 | auth = Auth(username="root", password="passwd")
14 |
15 | # Connect to "_system" database as root user.
16 | sys_db = await client.db("_system", auth=auth)
17 |
18 | # Create a new database named "test".
19 | await sys_db.create_database("test")
20 |
21 | # Connect to "test" database as root user.
22 | db = await client.db("test", auth=auth)
23 |
24 | # Create a new collection named "students".
25 | students = await db.create_collection("students")
26 |
27 | # Add a persistent index to the collection.
28 | await students.add_index(type="persistent", fields=["name"], options={"unique": True})
29 |
30 | # Insert new documents into the collection.
31 | await students.insert({"name": "jane", "age": 39})
32 | await students.insert({"name": "josh", "age": 18})
33 | await students.insert({"name": "judy", "age": 21})
34 |
35 | # Execute an AQL query and iterate through the result cursor.
36 | cursor = await db.aql.execute("FOR doc IN students RETURN doc")
37 | async with cursor:
38 | student_names = []
39 | async for doc in cursor:
40 | student_names.append(doc["name"])
41 |
42 | You may also use the client without a context manager, but you must ensure to close the client when done.
43 |
44 | .. code-block:: python
45 |
46 | from arangoasync import ArangoClient
47 | from arangoasync.auth import Auth
48 |
49 | client = ArangoClient(hosts="http://localhost:8529")
50 | auth = Auth(username="root", password="passwd")
51 | sys_db = await client.db("_system", auth=auth)
52 |
53 | # Create a new database named "test".
54 | await sys_db.create_database("test")
55 |
56 | # Connect to "test" database as root user.
57 | db = await client.db("test", auth=auth)
58 |
59 | # List all collections in the "test" database.
60 | collections = await db.collections()
61 |
62 | # Close the client when done.
63 | await client.close()
64 |
65 | Another example with `graphs`_:
66 |
67 | .. _graphs: https://docs.arangodb.com/stable/graphs/
68 |
69 | .. code-block:: python
70 |
71 | from arangoasync import ArangoClient
72 | from arangoasync.auth import Auth
73 |
74 | # Initialize the client for ArangoDB.
75 | async with ArangoClient(hosts="http://localhost:8529") as client:
76 | auth = Auth(username="root", password="passwd")
77 |
78 | # Connect to "test" database as root user.
79 | db = await client.db("test", auth=auth)
80 |
81 | # Get the API wrapper for graph "school".
82 | if await db.has_graph("school"):
83 | graph = db.graph("school")
84 | else:
85 | graph = await db.create_graph("school")
86 |
87 | # Create vertex collections for the graph.
88 | students = await graph.create_vertex_collection("students")
89 | lectures = await graph.create_vertex_collection("lectures")
90 |
91 | # Create an edge definition (relation) for the graph.
92 | edges = await graph.create_edge_definition(
93 | edge_collection="register",
94 | from_vertex_collections=["students"],
95 | to_vertex_collections=["lectures"]
96 | )
97 |
98 | # Insert vertex documents into "students" (from) vertex collection.
99 | await students.insert({"_key": "01", "full_name": "Anna Smith"})
100 | await students.insert({"_key": "02", "full_name": "Jake Clark"})
101 | await students.insert({"_key": "03", "full_name": "Lisa Jones"})
102 |
103 | # Insert vertex documents into "lectures" (to) vertex collection.
104 | await lectures.insert({"_key": "MAT101", "title": "Calculus"})
105 | await lectures.insert({"_key": "STA101", "title": "Statistics"})
106 | await lectures.insert({"_key": "CSC101", "title": "Algorithms"})
107 |
108 | # Insert edge documents into "register" edge collection.
109 | await edges.insert({"_from": "students/01", "_to": "lectures/MAT101"})
110 | await edges.insert({"_from": "students/01", "_to": "lectures/STA101"})
111 | await edges.insert({"_from": "students/01", "_to": "lectures/CSC101"})
112 | await edges.insert({"_from": "students/02", "_to": "lectures/MAT101"})
113 | await edges.insert({"_from": "students/02", "_to": "lectures/STA101"})
114 | await edges.insert({"_from": "students/03", "_to": "lectures/CSC101"})
115 |
116 | # Traverse the graph in outbound direction, breath-first.
117 | query = """
118 | FOR v, e, p IN 1..3 OUTBOUND 'students/01' GRAPH 'school'
119 | OPTIONS { bfs: true, uniqueVertices: 'global' }
120 | RETURN {vertex: v, edge: e, path: p}
121 | """
122 |
123 | async with await db.aql.execute(query) as cursor:
124 | async for doc in cursor:
125 | print(doc)
126 |
--------------------------------------------------------------------------------
/docs/serialization.rst:
--------------------------------------------------------------------------------
1 | .. _Serialization:
2 |
3 | Serialization
4 | -------------
5 |
6 | There are two serialization mechanisms employed by the driver:
7 |
8 | * JSON serialization/deserialization
9 | * Document serialization/deserialization
10 |
11 | All serializers must inherit from the :class:`arangoasync.serialization.Serializer` class. They must
12 | implement a :func:`arangoasync.serialization.Serializer.dumps` method can handle both
13 | single objects and sequences.
14 |
15 | Deserializers mush inherit from the :class:`arangoasync.serialization.Deserializer` class. These have
16 | two methods, :func:`arangoasync.serialization.Deserializer.loads` and :func:`arangoasync.serialization.Deserializer.loads_many`,
17 | which must handle loading of a single document and multiple documents, respectively.
18 |
19 | JSON
20 | ====
21 |
22 | Usually there's no need to implement your own JSON serializer/deserializer, but such an
23 | implementation could look like the following.
24 |
25 | **Example:**
26 |
27 | .. code-block:: python
28 |
29 | import json
30 | from typing import Sequence, cast
31 | from arangoasync.collection import StandardCollection
32 | from arangoasync.database import StandardDatabase
33 | from arangoasync.exceptions import DeserializationError, SerializationError
34 | from arangoasync.serialization import Serializer, Deserializer
35 | from arangoasync.typings import Json, Jsons
36 |
37 |
38 | class CustomJsonSerializer(Serializer[Json]):
39 | def dumps(self, data: Json | Sequence[str | Json]) -> str:
40 | try:
41 | return json.dumps(data, separators=(",", ":"))
42 | except Exception as e:
43 | raise SerializationError("Failed to serialize data to JSON.") from e
44 |
45 |
46 | class CustomJsonDeserializer(Deserializer[Json, Jsons]):
47 | def loads(self, data: bytes) -> Json:
48 | try:
49 | return json.loads(data) # type: ignore[no-any-return]
50 | except Exception as e:
51 | raise DeserializationError("Failed to deserialize data from JSON.") from e
52 |
53 | def loads_many(self, data: bytes) -> Jsons:
54 | return self.loads(data) # type: ignore[return-value]
55 |
56 | You would then use the custom serializer/deserializer when creating a client:
57 |
58 | .. code-block:: python
59 |
60 | from arangoasync import ArangoClient
61 | from arangoasync.auth import Auth
62 |
63 | # Initialize the client for ArangoDB.
64 | async with ArangoClient(
65 | hosts="http://localhost:8529",
66 | serializer=CustomJsonSerializer(),
67 | deserializer=CustomJsonDeserializer(),
68 | ) as client:
69 | auth = Auth(username="root", password="passwd")
70 |
71 | # Connect to "test" database as root user.
72 | test = await client.db("test", auth=auth)
73 |
74 | Documents
75 | =========
76 |
77 | By default, the JSON serializer/deserializer is used for documents too, but you can provide your own
78 | document serializer and deserializer for fine-grained control over the format of a collection. Say
79 | that you are modeling your students data using Pydantic_. You want to be able to insert documents
80 | of a certain type, and also be able to read them back. More so, you would like to get multiple documents
81 | back using one of the formats provided by pandas_.
82 |
83 | .. note::
84 | The driver assumes that the types support dictionary-like indexing, i.e. `doc["_id"]`
85 | returns the id of the document.
86 |
87 | **Example:**
88 |
89 | .. code-block:: python
90 |
91 | import json
92 | import pandas as pd
93 | import pydantic
94 | import pydantic_core
95 | from typing import Sequence, cast
96 | from arangoasync import ArangoClient
97 | from arangoasync.auth import Auth
98 | from arangoasync.collection import StandardCollection
99 | from arangoasync.database import StandardDatabase
100 | from arangoasync.exceptions import DeserializationError, SerializationError
101 | from arangoasync.serialization import Serializer, Deserializer
102 | from arangoasync.typings import Json, Jsons
103 |
104 |
105 | class Student(pydantic.BaseModel):
106 | name: str
107 | age: int
108 |
109 |
110 | class StudentSerializer(Serializer[Student]):
111 | def dumps(self, data: Student | Sequence[Student | str]) -> str:
112 | try:
113 | if isinstance(data, Student):
114 | return data.model_dump_json()
115 | else:
116 | # You are required to support both str and Student types.
117 | serialized_data = []
118 | for student in data:
119 | if isinstance(student, str):
120 | serialized_data.append(student)
121 | else:
122 | serialized_data.append(student.model_dump())
123 | return json.dumps(serialized_data, separators=(",", ":"))
124 | except Exception as e:
125 | raise SerializationError("Failed to serialize data.") from e
126 |
127 |
128 | class StudentDeserializer(Deserializer[Student, pd.DataFrame]):
129 | def loads(self, data: bytes) -> Student:
130 | # Load a single document.
131 | try:
132 | return Student.model_validate(pydantic_core.from_json(data))
133 | except Exception as e:
134 | raise DeserializationError("Failed to deserialize data.") from e
135 |
136 | def loads_many(self, data: bytes) -> pd.DataFrame:
137 | # Load multiple documents.
138 | return pd.DataFrame(json.loads(data))
139 |
140 | You would then use the custom serializer/deserializer when working with collections.
141 |
142 | **Example:**
143 |
144 | .. code-block:: python
145 |
146 | async def main():
147 | # Initialize the client for ArangoDB.
148 | async with ArangoClient(
149 | hosts="http://localhost:8529",
150 | serializer=CustomJsonSerializer(),
151 | deserializer=CustomJsonDeserializer(),
152 | ) as client:
153 | auth = Auth(username="root", password="passwd")
154 |
155 | # Connect to "test" database as root user.
156 | db: StandardDatabase = await client.db("test", auth=auth, verify=True)
157 |
158 | # Populate the "students" collection.
159 | col = cast(
160 | StandardCollection[Student, Student, pd.DataFrame],
161 | db.collection(
162 | "students",
163 | doc_serializer=StudentSerializer(),
164 | doc_deserializer=StudentDeserializer()),
165 | )
166 |
167 | # Insert one document.
168 | doc = cast(Json, await col.insert(Student(name="John Doe", age=20)))
169 |
170 | # Insert multiple documents.
171 | docs = cast(Jsons, await col.insert_many([
172 | Student(name="Jane Doe", age=22),
173 | Student(name="Alice Smith", age=19),
174 | Student(name="Bob Johnson", age=21),
175 | ]))
176 |
177 | # Get one document.
178 | john = await col.get(doc)
179 | assert type(john) == Student
180 |
181 | # Get multiple documents.
182 | keys = [doc["_key"] for doc in docs]
183 | students = await col.get_many(keys)
184 | assert type(students) == pd.DataFrame
185 |
186 | See a full example in this `gist `__.
187 |
188 | .. _Pydantic: https://docs.pydantic.dev/latest/
189 | .. _pandas: https://pandas.pydata.org/
190 |
--------------------------------------------------------------------------------
/docs/specs.rst:
--------------------------------------------------------------------------------
1 | API Specification
2 | -----------------
3 |
4 | This page contains the specification for all classes and methods available in
5 | python-arango-async.
6 |
7 | .. automodule:: arangoasync.client
8 | :members:
9 |
10 | .. automodule:: arangoasync.auth
11 | :members:
12 |
13 | .. automodule:: arangoasync.database
14 | :members:
15 |
16 | .. automodule:: arangoasync.collection
17 | :members:
18 |
19 | .. automodule:: arangoasync.aql
20 | :members:
21 |
22 | .. automodule:: arangoasync.graph
23 | :members:
24 |
25 | .. automodule:: arangoasync.job
26 | :members:
27 |
28 | .. automodule:: arangoasync.cursor
29 | :members:
30 |
31 | .. automodule:: arangoasync.compression
32 | :members:
33 |
34 | .. automodule:: arangoasync.serialization
35 | :members:
36 |
37 | .. automodule:: arangoasync.connection
38 | :members:
39 |
40 | .. automodule:: arangoasync.http
41 | :members:
42 |
43 | .. automodule:: arangoasync.request
44 | :members:
45 |
46 | .. automodule:: arangoasync.resolver
47 | :members:
48 |
49 | .. automodule:: arangoasync.response
50 | :members:
51 |
52 | .. automodule:: arangoasync.result
53 | :members:
54 |
--------------------------------------------------------------------------------
/docs/static/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arangodb/python-arango-async/7a5d1985e5b8daa15dd39fc1b0bf3dfc6bb58251/docs/static/logo.png
--------------------------------------------------------------------------------
/docs/transaction.rst:
--------------------------------------------------------------------------------
1 | Transactions
2 | ------------
3 |
4 | In **transactions**, requests to ArangoDB server are committed as a single,
5 | logical unit of work (ACID compliant).
6 |
7 | **Example:**
8 |
9 | .. code-block:: python
10 |
11 | from arangoasync import ArangoClient
12 | from arangoasync.auth import Auth
13 |
14 | # Initialize the client for ArangoDB.
15 | async with ArangoClient(hosts="http://localhost:8529") as client:
16 | auth = Auth(username="root", password="passwd")
17 |
18 | # Connect to "test" database as root user.
19 | db = await client.db("test", auth=auth)
20 |
21 | # Get the API wrapper for "students" collection.
22 | students = db.collection("students")
23 |
24 | # Begin a transaction. Read and write collections must be declared ahead of
25 | # time. This returns an instance of TransactionDatabase, database-level
26 | # API wrapper tailored specifically for executing transactions.
27 | txn_db = await db.begin_transaction(read=students.name, write=students.name)
28 |
29 | # The API wrapper is specific to a single transaction with a unique ID.
30 | trx_id = txn_db.transaction_id
31 |
32 | # Child wrappers are also tailored only for the specific transaction.
33 | txn_aql = txn_db.aql
34 | txn_col = txn_db.collection("students")
35 |
36 | # API execution context is always set to "transaction".
37 | assert txn_db.context == "transaction"
38 | assert txn_aql.context == "transaction"
39 | assert txn_col.context == "transaction"
40 |
41 | assert "_rev" in await txn_col.insert({"_key": "Abby"})
42 | assert "_rev" in await txn_col.insert({"_key": "John"})
43 | assert "_rev" in await txn_col.insert({"_key": "Mary"})
44 |
45 | # Check the transaction status.
46 | status = await txn_db.transaction_status()
47 |
48 | # Commit the transaction.
49 | await txn_db.commit_transaction()
50 | assert await students.has("Abby")
51 | assert await students.has("John")
52 | assert await students.has("Mary")
53 | assert await students.count() == 3
54 |
55 | # Begin another transaction. Note that the wrappers above are specific to
56 | # the last transaction and cannot be reused. New ones must be created.
57 | txn_db = await db.begin_transaction(read=students.name, write=students.name)
58 | txn_col = txn_db.collection("students")
59 | assert "_rev" in await txn_col.insert({"_key": "Kate"})
60 | assert "_rev" in await txn_col.insert({"_key": "Mike"})
61 | assert "_rev" in await txn_col.insert({"_key": "Lily"})
62 | assert await txn_col.count() == 6
63 |
64 | # Abort the transaction
65 | await txn_db.abort_transaction()
66 | assert not await students.has("Kate")
67 | assert not await students.has("Mike")
68 | assert not await students.has("Lily")
69 | assert await students.count() == 3 # transaction is aborted so txn_col cannot be used
70 |
71 | # Fetch an existing transaction. Useful if you have received a Transaction ID
72 | # from an external system.
73 | original_txn = await db.begin_transaction(write='students')
74 | txn_col = original_txn.collection('students')
75 | assert '_rev' in await txn_col.insert({'_key': 'Chip'})
76 | txn_db = db.fetch_transaction(original_txn.transaction_id)
77 | txn_col = txn_db.collection('students')
78 | assert '_rev' in await txn_col.insert({'_key': 'Alya'})
79 | await txn_db.abort_transaction()
80 |
81 | See :class:`arangoasync.database.TransactionDatabase` for API specification.
82 |
--------------------------------------------------------------------------------
/docs/user.rst:
--------------------------------------------------------------------------------
1 | Users and Permissions
2 | ---------------------
3 |
4 | ArangoDB provides operations for managing users and permissions. Most of
5 | these operations can only be performed by admin users via ``_system`` database.
6 |
7 | .. code-block:: python
8 |
9 | from arangoasync import ArangoClient
10 | from arangoasync.auth import Auth
11 | from arangoasync.typings import UserInfo
12 |
13 | # Initialize the client for ArangoDB.
14 | async with ArangoClient(hosts="http://localhost:8529") as client:
15 | auth = Auth(username="root", password="passwd")
16 |
17 | # Connect to "_system" database as root user.
18 | sys_db = await client.db("_system", auth=auth)
19 |
20 | # List all users.
21 | users = await sys_db.users()
22 |
23 | johndoe = UserInfo(
24 | user="johndoe@gmail.com",
25 | password="first_password",
26 | active=True,
27 | extra={"team": "backend", "title": "engineer"}
28 | )
29 |
30 | # Create a new user.
31 | await sys_db.create_user(johndoe)
32 |
33 | # Check if a user exists.
34 | assert await sys_db.has_user(johndoe.user) is True
35 | assert await sys_db.has_user("johndoe@gmail.com") is True
36 |
37 | # Retrieve details of a user.
38 | user_info = await sys_db.user(johndoe.user)
39 | assert user_info.user == "johndoe@gmail.com"
40 |
41 | # Update an existing user.
42 | johndoe["password"] = "second_password"
43 | await sys_db.update_user(johndoe)
44 |
45 | # Replace an existing user.
46 | johndoe["password"] = "third_password"
47 | await sys_db.replace_user(johndoe)
48 |
49 | # Retrieve user permissions for all databases and collections.
50 | await sys_db.permissions(johndoe.user)
51 |
52 | # Retrieve user permission for "test" database.
53 | perm = await sys_db.permission(
54 | username="johndoe@gmail.com",
55 | database="test"
56 | )
57 |
58 | # Retrieve user permission for "students" collection in "test" database.
59 | perm = await sys_db.permission(
60 | username="johndoe@gmail.com",
61 | database="test",
62 | collection="students"
63 | )
64 |
65 | # Update user permission for "test" database.
66 | await sys_db.update_permission(
67 | username="johndoe@gmail.com",
68 | permission="rw",
69 | database="test"
70 | )
71 |
72 | # Update user permission for "students" collection in "test" database.
73 | await sys_db.update_permission(
74 | username="johndoe@gmail.com",
75 | permission="ro",
76 | database="test",
77 | collection="students"
78 | )
79 |
80 | # Reset user permission for "test" database.
81 | await sys_db.reset_permission(
82 | username="johndoe@gmail.com",
83 | database="test"
84 | )
85 |
86 | # Reset user permission for "students" collection in "test" database.
87 | await sys_db.reset_permission(
88 | username="johndoe@gmail.com",
89 | database="test",
90 | collection="students"
91 | )
92 |
93 | See :class:`arangoasync.database.StandardDatabase` for API specification.
94 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["setuptools>=42", "wheel", "setuptools_scm"]
3 | build-backend = "setuptools.build_meta"
4 |
5 | [tool.setuptools_scm]
6 | normalize = true
7 |
8 | [project]
9 | name = "python-arango-async"
10 | description = "Async Python Driver for ArangoDB"
11 | authors = [
12 | {name= "Alexandru Petenchea", email = "alexandru.petenchea@arangodb.com" },
13 | {name = "Anthony Mahanna", email = "anthony.mahanna@arangodb.com"}
14 | ]
15 | maintainers = [
16 | {name = "Alexandru Petenchea", email = "alexandru.petenchea@arangodb.com"},
17 | {name = "Anthony Mahanna", email = "anthony.mahanna@arangodb.com"}
18 | ]
19 | keywords = ["arangodb", "python", "driver", "async"]
20 | readme = "README.md"
21 | dynamic = ["version"]
22 | license = "MIT"
23 | license-files = ["LICENSE"]
24 | requires-python = ">=3.10"
25 |
26 | classifiers = [
27 | "Intended Audience :: Developers",
28 | "Natural Language :: English",
29 | "Operating System :: OS Independent",
30 | "Programming Language :: Python :: 3",
31 | "Programming Language :: Python :: 3.10",
32 | "Programming Language :: Python :: 3.11",
33 | "Programming Language :: Python :: 3.12",
34 | "Programming Language :: Python :: 3.13",
35 | "Topic :: Documentation :: Sphinx",
36 | "Typing :: Typed",
37 | ]
38 |
39 | dependencies = [
40 | "packaging>=23.1",
41 | "setuptools>=42",
42 | "aiohttp>=3.9",
43 | "multidict>=6.0",
44 | "pyjwt>=2.8.0",
45 | ]
46 |
47 | [tool.setuptools.dynamic]
48 | version = { attr = "arangoasync.version.__version__" }
49 |
50 | [project.optional-dependencies]
51 | dev = [
52 | "black>=24.2",
53 | "flake8>=7.0",
54 | "isort>=5.10",
55 | "mypy>=1.10",
56 | "pre-commit>=3.7",
57 | "pytest>=8.2",
58 | "pytest-asyncio>=0.23.8",
59 | "pytest-cov>=5.0",
60 | "sphinx>=7.3",
61 | "sphinx_rtd_theme>=2.0",
62 | "types-setuptools",
63 | ]
64 |
65 | [tool.setuptools.package-data]
66 | "arangoasync" = ["py.typed"]
67 |
68 | [project.urls]
69 | homepage = "https://github.com/arangodb/python-arango-async"
70 |
71 | [tool.setuptools]
72 | packages = ["arangoasync"]
73 |
74 |
75 | [tool.pytest.ini_options]
76 | addopts = "-s -vv -p no:warnings"
77 | minversion = "6.0"
78 | asyncio_mode = "auto"
79 | asyncio_default_fixture_loop_scope = "function"
80 | testpaths = ["tests"]
81 |
82 | [tool.coverage.run]
83 | omit = [
84 | "arangoasync/version.py",
85 | "setup.py",
86 | ]
87 |
88 | [tool.isort]
89 | profile = "black"
90 |
91 | [tool.mypy]
92 | warn_return_any = true
93 | warn_unused_configs = true
94 | ignore_missing_imports = true
95 | strict = true
96 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [flake8]
2 | max-line-length = 88
3 | extend-ignore = E203, E741, W503
4 | exclude =.git .idea .*_cache dist htmlcov venv arangoasync/errno.py
5 | per-file-ignores = __init__.py:F401
6 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from setuptools import setup
2 |
3 | setup()
4 |
--------------------------------------------------------------------------------
/starter.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Starts a local ArangoDB server or cluster (community or enterprise).
4 | # Useful for testing the python-arango driver against a local ArangoDB setup.
5 |
6 | # Usage:
7 | # ./starter.sh [single|cluster] [community|enterprise] [version]
8 | # Example:
9 | # ./starter.sh cluster enterprise 3.11.4
10 |
11 | setup="${1:-single}"
12 | license="${2:-community}"
13 | version="${3:-latest}"
14 |
15 | extra_ports=""
16 | if [ "$setup" == "single" ]; then
17 | echo ""
18 | elif [ "$setup" == "cluster" ]; then
19 | extra_ports="-p 8539:8539 -p 8549:8549"
20 | else
21 | echo "Invalid argument. Please provide either 'single' or 'cluster'."
22 | exit 1
23 | fi
24 |
25 | image_name=""
26 | if [ "$license" == "community" ]; then
27 | image_name="arangodb"
28 | elif [ "$license" == "enterprise" ]; then
29 | image_name="enterprise"
30 | else
31 | echo "Invalid argument. Please provide either 'community' or 'enterprise'."
32 | exit 1
33 | fi
34 |
35 | if [ "$version" == "latest" ]; then
36 | conf_file="${setup}-3.12"
37 | elif [[ "$version" == *.*.* ]]; then
38 | conf_file="${setup}-${version%.*}"
39 | else
40 | conf_file="${setup}-${version}"
41 | fi
42 |
43 | docker run -d \
44 | --name arango \
45 | -p 8528:8528 \
46 | -p 8529:8529 \
47 | $extra_ports \
48 | -v "$(pwd)/tests/static/":/tests/static \
49 | -v /tmp:/tmp \
50 | "arangodb/$image_name:$version" \
51 | /bin/sh -c "arangodb --configuration=/tests/static/$conf_file.conf"
52 |
53 | wget --quiet --waitretry=1 --tries=120 -O - http://localhost:8528/version | jq
54 | if [ $? -eq 0 ]; then
55 | echo "OK starter ready"
56 | exit 0
57 | else
58 | echo "ERROR starter not ready, giving up"
59 | exit 1
60 | fi
61 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arangodb/python-arango-async/7a5d1985e5b8daa15dd39fc1b0bf3dfc6bb58251/tests/__init__.py
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | from dataclasses import dataclass
3 |
4 | import pytest
5 | import pytest_asyncio
6 | from packaging import version
7 |
8 | from arangoasync.auth import Auth, JwtToken
9 | from arangoasync.client import ArangoClient
10 | from arangoasync.typings import UserInfo
11 | from tests.helpers import (
12 | generate_col_name,
13 | generate_db_name,
14 | generate_graph_name,
15 | generate_username,
16 | )
17 |
18 |
19 | @dataclass
20 | class GlobalData:
21 | url: str = None
22 | root: str = None
23 | password: str = None
24 | secret: str = None
25 | token: JwtToken = None
26 | sys_db_name: str = "_system"
27 | graph_name: str = "test_graph"
28 | username: str = generate_username()
29 | cluster: bool = False
30 | enterprise: bool = False
31 | db_version: version = version.parse("0.0.0")
32 |
33 |
34 | global_data = GlobalData()
35 |
36 |
37 | def pytest_addoption(parser):
38 | parser.addoption(
39 | "--host", action="store", default="127.0.0.1", help="ArangoDB host address"
40 | )
41 | parser.addoption(
42 | "--port", action="append", default=["8529"], help="ArangoDB coordinator ports"
43 | )
44 | parser.addoption(
45 | "--root", action="store", default="root", help="ArangoDB root user"
46 | )
47 | parser.addoption(
48 | "--password", action="store", default="passwd", help="ArangoDB password"
49 | )
50 | parser.addoption(
51 | "--secret", action="store", default="secret", help="ArangoDB JWT secret"
52 | )
53 | parser.addoption(
54 | "--cluster", action="store_true", help="Run tests in a cluster setup"
55 | )
56 | parser.addoption(
57 | "--enterprise", action="store_true", help="Run tests in an enterprise setup"
58 | )
59 |
60 |
61 | def pytest_configure(config):
62 | ports = config.getoption("port")
63 | hosts = [f"http://{config.getoption('host')}:{p}" for p in ports]
64 | url = hosts[0]
65 |
66 | global_data.url = url
67 | global_data.root = config.getoption("root")
68 | global_data.password = config.getoption("password")
69 | global_data.secret = config.getoption("secret")
70 | global_data.token = JwtToken.generate_token(global_data.secret)
71 | global_data.cluster = config.getoption("cluster")
72 | global_data.enterprise = config.getoption("enterprise")
73 | global_data.graph_name = generate_graph_name()
74 |
75 | async def get_db_version():
76 | async with ArangoClient(hosts=global_data.url) as client:
77 | sys_db = await client.db(
78 | global_data.sys_db_name,
79 | auth_method="basic",
80 | auth=Auth(global_data.root, global_data.password),
81 | verify=False,
82 | )
83 | db_version = (await sys_db.version())["version"]
84 | global_data.db_version = version.parse(db_version.split("-")[0])
85 |
86 | asyncio.run(get_db_version())
87 |
88 |
89 | @pytest.fixture
90 | def url():
91 | return global_data.url
92 |
93 |
94 | @pytest.fixture
95 | def root():
96 | return global_data.root
97 |
98 |
99 | @pytest.fixture
100 | def password():
101 | return global_data.password
102 |
103 |
104 | @pytest.fixture
105 | def basic_auth_root(root, password):
106 | return Auth(username=root, password=password)
107 |
108 |
109 | @pytest.fixture
110 | def cluster():
111 | return global_data.cluster
112 |
113 |
114 | @pytest.fixture
115 | def enterprise():
116 | return global_data.enterprise
117 |
118 |
119 | @pytest.fixture
120 | def username():
121 | return global_data.username
122 |
123 |
124 | @pytest.fixture
125 | def token():
126 | return global_data.token
127 |
128 |
129 | @pytest.fixture
130 | def sys_db_name():
131 | return global_data.sys_db_name
132 |
133 |
134 | @pytest.fixture
135 | def docs():
136 | return [
137 | {"_key": "1", "val": 1, "text": "foo", "loc": [1, 1]},
138 | {"_key": "2", "val": 2, "text": "foo", "loc": [2, 2]},
139 | {"_key": "3", "val": 3, "text": "foo", "loc": [3, 3]},
140 | {"_key": "4", "val": 4, "text": "bar", "loc": [4, 4]},
141 | {"_key": "5", "val": 5, "text": "bar", "loc": [5, 5]},
142 | {"_key": "6", "val": 6, "text": "bar", "loc": [5, 5]},
143 | ]
144 |
145 |
146 | @pytest.fixture(scope="session")
147 | def event_loop():
148 | loop = asyncio.new_event_loop()
149 | yield loop
150 | loop.close()
151 |
152 |
153 | @pytest_asyncio.fixture
154 | async def client_session():
155 | """Make sure we close all sessions after the test is done."""
156 | sessions = []
157 |
158 | def get_client_session(client, url):
159 | s = client.create_session(url)
160 | sessions.append(s)
161 | return s
162 |
163 | yield get_client_session
164 |
165 | for session in sessions:
166 | await session.close()
167 |
168 |
169 | @pytest_asyncio.fixture
170 | async def arango_client(url):
171 | async with ArangoClient(hosts=url) as client:
172 | yield client
173 |
174 |
175 | @pytest_asyncio.fixture
176 | async def sys_db(arango_client, sys_db_name, basic_auth_root):
177 | return await arango_client.db(
178 | sys_db_name, auth_method="basic", auth=basic_auth_root, verify=False
179 | )
180 |
181 |
182 | @pytest_asyncio.fixture
183 | async def superuser(arango_client, sys_db_name, basic_auth_root, token):
184 | return await arango_client.db(
185 | sys_db_name, auth_method="superuser", token=token, verify=False
186 | )
187 |
188 |
189 | @pytest_asyncio.fixture
190 | async def db(arango_client, sys_db, username, password, cluster):
191 | tst_db_name = generate_db_name()
192 | tst_user = UserInfo(
193 | user=username,
194 | password=password,
195 | active=True,
196 | )
197 | tst_db_kwargs = dict(name=tst_db_name, users=[tst_user])
198 | if cluster:
199 | tst_db_kwargs.update(
200 | dict(
201 | replication_factor=3,
202 | write_concern=2,
203 | )
204 | )
205 | await sys_db.create_database(**tst_db_kwargs)
206 | yield await arango_client.db(
207 | tst_db_name,
208 | auth_method="basic",
209 | auth=Auth(username=username, password=password),
210 | verify=False,
211 | )
212 | await sys_db.delete_database(tst_db_name)
213 |
214 |
215 | @pytest_asyncio.fixture
216 | async def bad_db(arango_client):
217 | return await arango_client.db(
218 | generate_db_name(),
219 | auth_method="basic",
220 | auth=Auth(username="bad_user", password="bad_password"),
221 | verify=False,
222 | )
223 |
224 |
225 | @pytest_asyncio.fixture
226 | def bad_graph(bad_db):
227 | return bad_db.graph(global_data.graph_name)
228 |
229 |
230 | @pytest_asyncio.fixture
231 | async def doc_col(db):
232 | col_name = generate_col_name()
233 | yield await db.create_collection(col_name)
234 | await db.delete_collection(col_name)
235 |
236 |
237 | @pytest.fixture
238 | def bad_col(db):
239 | col_name = generate_col_name()
240 | return db.collection(col_name)
241 |
242 |
243 | @pytest.fixture
244 | def db_version():
245 | return global_data.db_version
246 |
247 |
248 | @pytest_asyncio.fixture(autouse=True)
249 | async def teardown():
250 | yield
251 | async with ArangoClient(hosts=global_data.url) as client:
252 | sys_db = await client.db(
253 | global_data.sys_db_name,
254 | auth_method="basic",
255 | auth=Auth(username=global_data.root, password=global_data.password),
256 | verify=False,
257 | )
258 |
259 | # Remove all test users.
260 | tst_users = [
261 | user["user"]
262 | for user in await sys_db.users()
263 | if user["user"].startswith("test_user")
264 | ]
265 | await asyncio.gather(*(sys_db.delete_user(user) for user in tst_users))
266 |
267 | # Remove all test databases.
268 | tst_dbs = [
269 | db_name
270 | for db_name in await sys_db.databases()
271 | if db_name.startswith("test_database")
272 | ]
273 | await asyncio.gather(*(sys_db.delete_database(db_name) for db_name in tst_dbs))
274 |
275 | # Remove all test collections.
276 | tst_cols = [
277 | col_info.name
278 | for col_info in await sys_db.collections()
279 | if col_info.name.startswith("test_collection")
280 | ]
281 | await asyncio.gather(
282 | *(sys_db.delete_collection(col_name) for col_name in tst_cols)
283 | )
284 |
--------------------------------------------------------------------------------
/tests/helpers.py:
--------------------------------------------------------------------------------
1 | from uuid import uuid4
2 |
3 |
4 | def generate_db_name():
5 | """Generate and return a random database name.
6 |
7 | Returns:
8 | str: Random database name.
9 | """
10 | return f"test_database_{uuid4().hex}"
11 |
12 |
13 | def generate_col_name():
14 | """Generate and return a random collection name.
15 |
16 | Returns:
17 | str: Random collection name.
18 | """
19 | return f"test_collection_{uuid4().hex}"
20 |
21 |
22 | def generate_graph_name():
23 | """Generate and return a random graph name.
24 |
25 | Returns:
26 | str: Random graph name.
27 | """
28 | return f"test_graph_{uuid4().hex}"
29 |
30 |
31 | def generate_username():
32 | """Generate and return a random username.
33 |
34 | Returns:
35 | str: Random username.
36 | """
37 | return f"test_user_{uuid4().hex}"
38 |
39 |
40 | def generate_string():
41 | """Generate and return a random unique string.
42 |
43 | Returns:
44 | str: Random unique string.
45 | """
46 | return uuid4().hex
47 |
--------------------------------------------------------------------------------
/tests/static/cluster-3.11.conf:
--------------------------------------------------------------------------------
1 | [starter]
2 | mode = cluster
3 | local = true
4 | address = 0.0.0.0
5 | port = 8528
6 |
7 | [auth]
8 | jwt-secret = /tests/static/keyfile
9 |
10 | [args]
11 | all.database.password = passwd
12 | all.database.extended-names = true
13 | all.log.api-enabled = true
14 | all.javascript.allow-admin-execute = true
15 |
--------------------------------------------------------------------------------
/tests/static/cluster-3.12.conf:
--------------------------------------------------------------------------------
1 | [starter]
2 | mode = cluster
3 | local = true
4 | address = 0.0.0.0
5 | port = 8528
6 |
7 | [auth]
8 | jwt-secret = /tests/static/keyfile
9 |
10 | [args]
11 | all.database.password = passwd
12 | all.database.extended-names = true
13 | all.log.api-enabled = true
14 | all.javascript.allow-admin-execute = true
15 | all.server.options-api = admin
16 |
--------------------------------------------------------------------------------
/tests/static/keyfile:
--------------------------------------------------------------------------------
1 | secret
2 |
--------------------------------------------------------------------------------
/tests/static/single-3.11.conf:
--------------------------------------------------------------------------------
1 | [starter]
2 | mode = single
3 | address = 0.0.0.0
4 | port = 8528
5 |
6 | [auth]
7 | jwt-secret = /tests/static/keyfile
8 |
9 | [args]
10 | all.database.password = passwd
11 | all.database.extended-names = true
12 | all.javascript.allow-admin-execute = true
13 |
--------------------------------------------------------------------------------
/tests/static/single-3.12.conf:
--------------------------------------------------------------------------------
1 | [starter]
2 | mode = single
3 | address = 0.0.0.0
4 | port = 8528
5 |
6 | [auth]
7 | jwt-secret = /tests/static/keyfile
8 |
9 | [args]
10 | all.database.password = passwd
11 | all.database.extended-names = true
12 | all.javascript.allow-admin-execute = true
13 | all.server.options-api = admin
14 |
--------------------------------------------------------------------------------
/tests/test_async.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import time
3 |
4 | import pytest
5 |
6 | from arangoasync.exceptions import (
7 | AQLQueryExecuteError,
8 | AsyncJobCancelError,
9 | AsyncJobListError,
10 | AsyncJobResultError,
11 | )
12 |
13 |
14 | @pytest.mark.asyncio
15 | async def test_async_no_result(db, bad_db, doc_col, docs):
16 | # There should be no jobs to begin with
17 | jobs = await db.async_jobs(status="pending")
18 | assert len(jobs) == 0
19 | with pytest.raises(AsyncJobListError):
20 | await bad_db.async_jobs(status="pending")
21 |
22 | # Create a basic job
23 | async_db = db.begin_async_execution(return_result=False)
24 | async_col = async_db.collection(doc_col.name)
25 |
26 | # Should return None, because return_result=False
27 | job1 = await async_col.insert(docs[0])
28 | assert job1 is None
29 | time.sleep(1)
30 | # There should be none pending or done
31 | jobs_pending, jobs_done = await asyncio.gather(
32 | db.async_jobs(status="pending"),
33 | db.async_jobs(status="done"),
34 | )
35 | assert len(jobs_pending) == 0
36 | assert len(jobs_done) == 0
37 |
38 | # Create a long-running job
39 | aql = async_db.aql
40 | job2, job3 = await asyncio.gather(
41 | aql.execute("RETURN SLEEP(5)"), aql.execute("RETURN SLEEP(5)")
42 | )
43 | time.sleep(1)
44 | assert job2 is None
45 | assert job3 is None
46 | jobs_pending, jobs_done = await asyncio.gather(
47 | db.async_jobs(status="pending"),
48 | db.async_jobs(status="done"),
49 | )
50 | assert len(jobs_pending) == 0
51 | assert len(jobs_done) == 0
52 |
53 | with pytest.raises(AsyncJobListError):
54 | await db.async_jobs(status="invalid-parameter")
55 | with pytest.raises(AsyncJobListError):
56 | await bad_db.async_jobs(status="pending")
57 |
58 |
59 | @pytest.mark.asyncio
60 | async def test_async_result(db, bad_db, doc_col, docs):
61 | # There should be no jobs to begin with
62 | jobs = await db.async_jobs(status="pending")
63 | assert len(jobs) == 0
64 |
65 | # Create a basic job and wait for it to finish
66 | async_db = db.begin_async_execution(return_result=True)
67 | async_col = async_db.collection(doc_col.name)
68 | job1 = await async_col.insert(docs[0])
69 | await job1.wait()
70 | assert await job1.status() == "done"
71 | res = await job1.result()
72 | assert isinstance(res, dict)
73 |
74 | # Check that exceptions are being propagated correctly
75 | aql = async_db.aql
76 | job2 = await aql.execute("INVALID QUERY")
77 | await job2.wait()
78 | with pytest.raises(AQLQueryExecuteError):
79 | _ = await job2.result()
80 |
81 | # Long-running job
82 | job3 = await aql.execute("RETURN SLEEP(5)")
83 | time.sleep(1)
84 | assert await job3.status() == "pending"
85 | jobs = await db.async_jobs(status="pending")
86 | assert len(jobs) == 1
87 | await job3.wait()
88 |
89 | # Clear jobs for which result has not been claimed
90 | jobs = await db.async_jobs(status="done")
91 | assert len(jobs) == 1
92 | await db.clear_async_jobs()
93 | jobs = await db.async_jobs(status="done")
94 | assert len(jobs) == 0
95 |
96 | # Attempt to cancel a finished job
97 | assert await job3.cancel(ignore_missing=True) is False
98 | with pytest.raises(AsyncJobCancelError):
99 | await job3.cancel()
100 |
101 | # Attempt to clear a single job
102 | job4 = await aql.execute("RETURN 1")
103 | await job4.wait()
104 | await job4.clear()
105 |
106 | # Attempt to get the result of a pending job
107 | job5 = await aql.execute("RETURN SLEEP(5)")
108 | time.sleep(1)
109 | with pytest.raises(AsyncJobResultError):
110 | _ = await job5.result()
111 | await job5.wait()
112 |
113 |
114 | @pytest.mark.asyncio
115 | async def test_async_cursor(db, doc_col, docs):
116 | # Insert some documents first
117 | await asyncio.gather(*(doc_col.insert(doc) for doc in docs))
118 |
119 | async_db = db.begin_async_execution()
120 | aql = async_db.aql
121 | job = await aql.execute(
122 | f"FOR d IN {doc_col.name} SORT d._key RETURN d",
123 | count=True,
124 | batch_size=1,
125 | ttl=1000,
126 | )
127 | await job.wait()
128 |
129 | # Get the cursor. Bear in mind that its underlying executor is no longer async.
130 | doc_cnt = 0
131 | cursor = await job.result()
132 | async with cursor as ctx:
133 | async for _ in ctx:
134 | doc_cnt += 1
135 | assert doc_cnt == len(docs)
136 |
--------------------------------------------------------------------------------
/tests/test_client.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from arangoasync.auth import JwtToken
4 | from arangoasync.client import ArangoClient
5 | from arangoasync.compression import DefaultCompressionManager
6 | from arangoasync.http import DefaultHTTPClient
7 | from arangoasync.resolver import DefaultHostResolver, RoundRobinHostResolver
8 | from arangoasync.version import __version__
9 |
10 |
11 | @pytest.mark.asyncio
12 | async def test_client_attributes(monkeypatch):
13 | hosts = ["http://127.0.0.1:8529", "http://localhost:8529"]
14 |
15 | async with ArangoClient(hosts=hosts[0]) as client:
16 | assert client.version == __version__
17 | assert client.hosts == [hosts[0]]
18 | assert repr(client) == f""
19 | assert isinstance(client.host_resolver, DefaultHostResolver)
20 | assert client.compression is None
21 | assert len(client.sessions) == 1
22 |
23 | with pytest.raises(ValueError):
24 | async with ArangoClient(hosts=hosts, host_resolver="invalid") as _:
25 | pass
26 |
27 | http_client = DefaultHTTPClient()
28 | create_session = 0
29 | close_session = 0
30 |
31 | class MockSession:
32 | async def close(self):
33 | nonlocal close_session
34 | close_session += 1
35 |
36 | def mock_method(*args, **kwargs):
37 | nonlocal create_session
38 | create_session += 1
39 | return MockSession()
40 |
41 | monkeypatch.setattr(http_client, "create_session", mock_method)
42 | async with ArangoClient(
43 | hosts=hosts,
44 | host_resolver="roundrobin",
45 | http_client=http_client,
46 | compression=DefaultCompressionManager(threshold=5000),
47 | ) as client:
48 | assert repr(client) == f""
49 | assert isinstance(client.host_resolver, RoundRobinHostResolver)
50 | assert isinstance(client.compression, DefaultCompressionManager)
51 | assert client.compression.threshold == 5000
52 | assert len(client.sessions) == len(hosts)
53 | assert create_session == 2
54 | assert close_session == 2
55 |
56 |
57 | @pytest.mark.asyncio
58 | async def test_client_bad_auth_method(url, sys_db_name):
59 | async with ArangoClient(hosts=url) as client:
60 | with pytest.raises(ValueError):
61 | await client.db(sys_db_name, auth_method="invalid")
62 |
63 |
64 | @pytest.mark.asyncio
65 | async def test_client_basic_auth(url, sys_db_name, basic_auth_root):
66 | # successful authentication
67 | async with ArangoClient(hosts=url) as client:
68 | await client.db(
69 | sys_db_name,
70 | auth_method="basic",
71 | auth=basic_auth_root,
72 | verify=True,
73 | )
74 |
75 | # auth missing
76 | async with ArangoClient(hosts=url) as client:
77 | with pytest.raises(ValueError):
78 | await client.db(
79 | sys_db_name,
80 | auth_method="basic",
81 | auth=None,
82 | token=JwtToken.generate_token("test"),
83 | verify=True,
84 | )
85 |
86 |
87 | @pytest.mark.asyncio
88 | async def test_client_jwt_auth(url, sys_db_name, basic_auth_root):
89 | token: JwtToken
90 |
91 | # successful authentication with auth only
92 | async with ArangoClient(hosts=url) as client:
93 | db = await client.db(
94 | sys_db_name,
95 | auth_method="jwt",
96 | auth=basic_auth_root,
97 | verify=True,
98 | )
99 | token = db.connection.token
100 |
101 | # successful authentication with token only
102 | async with ArangoClient(hosts=url) as client:
103 | await client.db(sys_db_name, auth_method="jwt", token=token, verify=True)
104 |
105 | # successful authentication with both
106 | async with ArangoClient(hosts=url) as client:
107 | await client.db(
108 | sys_db_name,
109 | auth_method="jwt",
110 | auth=basic_auth_root,
111 | token=token,
112 | verify=True,
113 | )
114 |
115 | # auth and token missing
116 | async with ArangoClient(hosts=url) as client:
117 | with pytest.raises(ValueError):
118 | await client.db(sys_db_name, auth_method="jwt", verify=True)
119 |
120 |
121 | @pytest.mark.asyncio
122 | async def test_client_jwt_superuser_auth(
123 | url, sys_db_name, basic_auth_root, token, enterprise
124 | ):
125 | # successful authentication
126 | async with ArangoClient(hosts=url) as client:
127 | db = await client.db(
128 | sys_db_name, auth_method="superuser", token=token, verify=True
129 | )
130 | if enterprise:
131 | await db.jwt_secrets()
132 | await db.reload_jwt_secrets()
133 |
134 | # token missing
135 | async with ArangoClient(hosts=url) as client:
136 | with pytest.raises(ValueError):
137 | await client.db(
138 | sys_db_name, auth_method="superuser", auth=basic_auth_root, verify=True
139 | )
140 |
--------------------------------------------------------------------------------
/tests/test_collection.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 |
3 | import pytest
4 |
5 | from arangoasync.errno import DATA_SOURCE_NOT_FOUND, INDEX_NOT_FOUND
6 | from arangoasync.exceptions import (
7 | CollectionPropertiesError,
8 | CollectionTruncateError,
9 | DocumentCountError,
10 | IndexCreateError,
11 | IndexDeleteError,
12 | IndexGetError,
13 | IndexListError,
14 | IndexLoadError,
15 | )
16 |
17 |
18 | def test_collection_attributes(db, doc_col):
19 | assert doc_col.db_name == db.name
20 | assert doc_col.name.startswith("test_collection")
21 | assert repr(doc_col) == f""
22 |
23 |
24 | @pytest.mark.asyncio
25 | async def test_collection_misc_methods(doc_col, bad_col):
26 | # Properties
27 | properties = await doc_col.properties()
28 | assert properties.name == doc_col.name
29 | assert properties.is_system is False
30 | assert len(properties.format()) > 1
31 | with pytest.raises(CollectionPropertiesError):
32 | await bad_col.properties()
33 |
34 |
35 | @pytest.mark.asyncio
36 | async def test_collection_index(doc_col, bad_col, cluster):
37 | # Create indexes
38 | idx1 = await doc_col.add_index(
39 | type="persistent",
40 | fields=["_key"],
41 | options={
42 | "unique": True,
43 | "name": "idx1",
44 | },
45 | )
46 | assert idx1.id is not None
47 | assert idx1.id == f"{doc_col.name}/{idx1.numeric_id}"
48 | assert idx1.type == "persistent"
49 | assert idx1["type"] == "persistent"
50 | assert idx1.fields == ["_key"]
51 | assert idx1.name == "idx1"
52 | assert idx1["unique"] is True
53 | assert idx1.unique is True
54 | assert idx1.format()["id"] == str(idx1.numeric_id)
55 |
56 | idx2 = await doc_col.add_index(
57 | type="inverted",
58 | fields=[{"name": "attr1", "cache": True}],
59 | options={
60 | "unique": False,
61 | "sparse": True,
62 | "name": "idx2",
63 | "storedValues": [{"fields": ["a"], "compression": "lz4", "cache": True}],
64 | "includeAllFields": True,
65 | "analyzer": "identity",
66 | "primarySort": {
67 | "cache": True,
68 | "fields": [{"field": "a", "direction": "asc"}],
69 | },
70 | },
71 | )
72 | assert idx2.id is not None
73 | assert idx2.id == f"{doc_col.name}/{idx2.numeric_id}"
74 | assert idx2.type == "inverted"
75 | assert idx2["fields"][0]["name"] == "attr1"
76 | assert idx2.name == "idx2"
77 | assert idx2.include_all_fields is True
78 | assert idx2.analyzer == "identity"
79 | assert idx2.sparse is True
80 | assert idx2.unique is False
81 |
82 | idx3 = await doc_col.add_index(
83 | type="geo",
84 | fields=["location"],
85 | options={
86 | "geoJson": True,
87 | "name": "idx3",
88 | "inBackground": True,
89 | },
90 | )
91 | assert idx3.id is not None
92 | assert idx3.type == "geo"
93 | assert idx3.fields == ["location"]
94 | assert idx3.name == "idx3"
95 | assert idx3.geo_json is True
96 | if cluster:
97 | assert idx3.in_background is True
98 |
99 | with pytest.raises(IndexCreateError):
100 | await bad_col.add_index(type="persistent", fields=["_key"])
101 |
102 | # List all indexes
103 | indexes = await doc_col.indexes()
104 | assert len(indexes) > 3, indexes
105 | found_idx1 = found_idx2 = found_idx3 = False
106 | for idx in indexes:
107 | if idx.id == idx1.id:
108 | found_idx1 = True
109 | elif idx.id == idx2.id:
110 | found_idx2 = True
111 | elif idx.id == idx3.id:
112 | found_idx3 = True
113 | assert found_idx1 is True, indexes
114 | assert found_idx2 is True, indexes
115 | assert found_idx3 is True, indexes
116 |
117 | with pytest.raises(IndexListError) as err:
118 | await bad_col.indexes()
119 | assert err.value.error_code == DATA_SOURCE_NOT_FOUND
120 |
121 | # Get an index
122 | get1, get2, get3 = await asyncio.gather(
123 | doc_col.get_index(idx1.id),
124 | doc_col.get_index(idx2.numeric_id),
125 | doc_col.get_index(str(idx3.numeric_id)),
126 | )
127 | assert get1.id == idx1.id
128 | assert get1.type == idx1.type
129 | assert get1.name == idx1.name
130 | assert get2.id == idx2.id
131 | assert get2.type == idx2.type
132 | assert get2.name == idx2.name
133 | assert get3.id == idx3.id
134 | assert get3.type == idx3.type
135 | assert get3.name == idx3.name
136 |
137 | with pytest.raises(IndexGetError) as err:
138 | await doc_col.get_index("non-existent")
139 | assert err.value.error_code == INDEX_NOT_FOUND
140 |
141 | # Load indexes into main memory
142 | assert await doc_col.load_indexes() is True
143 | with pytest.raises(IndexLoadError) as err:
144 | await bad_col.load_indexes()
145 | assert err.value.error_code == DATA_SOURCE_NOT_FOUND
146 |
147 | # Delete indexes
148 | del1, del2, del3 = await asyncio.gather(
149 | doc_col.delete_index(idx1.id),
150 | doc_col.delete_index(idx2.numeric_id),
151 | doc_col.delete_index(str(idx3.numeric_id)),
152 | )
153 | assert del1 is True
154 | assert del2 is True
155 | assert del3 is True
156 |
157 | # Now, the indexes should be gone
158 | with pytest.raises(IndexDeleteError) as err:
159 | await doc_col.delete_index(idx1.id)
160 | assert err.value.error_code == INDEX_NOT_FOUND
161 | assert await doc_col.delete_index(idx2.id, ignore_missing=True) is False
162 |
163 |
164 | @pytest.mark.asyncio
165 | async def test_collection_truncate_count(docs, doc_col, bad_col):
166 | # Test errors
167 | with pytest.raises(CollectionTruncateError):
168 | await bad_col.truncate()
169 | with pytest.raises(DocumentCountError):
170 | await bad_col.count()
171 |
172 | # Test regular operations
173 | await asyncio.gather(*[doc_col.insert(doc) for doc in docs])
174 | cnt = await doc_col.count()
175 | assert cnt == len(docs)
176 |
177 | await doc_col.truncate()
178 | cnt = await doc_col.count()
179 | assert cnt == 0
180 |
181 | await asyncio.gather(*[doc_col.insert(doc) for doc in docs])
182 | await doc_col.truncate(wait_for_sync=True, compact=True)
183 | cnt = await doc_col.count()
184 | assert cnt == 0
185 |
--------------------------------------------------------------------------------
/tests/test_compression.py:
--------------------------------------------------------------------------------
1 | from arangoasync.compression import AcceptEncoding, DefaultCompressionManager
2 |
3 |
4 | def test_DefaultCompressionManager_no_compression():
5 | manager = DefaultCompressionManager()
6 | assert not manager.needs_compression("test")
7 | assert not manager.needs_compression(b"test")
8 | manager = DefaultCompressionManager(threshold=10)
9 | assert not manager.needs_compression("test")
10 |
11 |
12 | def test_DefaultCompressionManager_compress():
13 | manager = DefaultCompressionManager(
14 | threshold=1, level=9, accept=AcceptEncoding.DEFLATE
15 | )
16 | data = "a" * 10 + "b" * 10
17 | assert manager.needs_compression(data)
18 | assert len(manager.compress(data)) < len(data)
19 | assert manager.content_encoding == "deflate"
20 | assert manager.accept_encoding == "deflate"
21 | data = b"a" * 10 + b"b" * 10
22 | assert manager.needs_compression(data)
23 | assert len(manager.compress(data)) < len(data)
24 |
25 |
26 | def test_DefaultCompressionManager_properties():
27 | manager = DefaultCompressionManager(
28 | threshold=1, level=9, accept=AcceptEncoding.DEFLATE
29 | )
30 | assert manager.threshold == 1
31 | assert manager.level == 9
32 | assert manager.accept_encoding == "deflate"
33 | assert manager.content_encoding == "deflate"
34 | manager.threshold = 10
35 | assert manager.threshold == 10
36 | manager.level = 2
37 | assert manager.level == 2
38 | manager.accept_encoding = AcceptEncoding.GZIP
39 | assert manager.accept_encoding == "gzip"
40 |
--------------------------------------------------------------------------------
/tests/test_connection.py:
--------------------------------------------------------------------------------
1 | import zlib
2 |
3 | import pytest
4 |
5 | from arangoasync.auth import Auth, JwtToken
6 | from arangoasync.compression import AcceptEncoding, DefaultCompressionManager
7 | from arangoasync.connection import (
8 | BasicConnection,
9 | JwtConnection,
10 | JwtSuperuserConnection,
11 | )
12 | from arangoasync.exceptions import (
13 | ClientConnectionAbortedError,
14 | ClientConnectionError,
15 | ServerConnectionError,
16 | )
17 | from arangoasync.http import AioHTTPClient
18 | from arangoasync.request import Method, Request
19 | from arangoasync.resolver import DefaultHostResolver
20 | from arangoasync.response import Response
21 |
22 |
23 | @pytest.mark.asyncio
24 | async def test_BasicConnection_ping_failed(client_session, url, sys_db_name):
25 | client = AioHTTPClient()
26 | session = client_session(client, url)
27 | resolver = DefaultHostResolver(1)
28 |
29 | connection = BasicConnection(
30 | sessions=[session],
31 | host_resolver=resolver,
32 | http_client=client,
33 | db_name=sys_db_name,
34 | )
35 |
36 | with pytest.raises(ServerConnectionError):
37 | await connection.ping()
38 |
39 |
40 | @pytest.mark.asyncio
41 | async def test_BasicConnection_ping_success(
42 | client_session, url, sys_db_name, root, password
43 | ):
44 | client = AioHTTPClient()
45 | session = client_session(client, url)
46 | resolver = DefaultHostResolver(1)
47 |
48 | connection = BasicConnection(
49 | sessions=[session],
50 | host_resolver=resolver,
51 | http_client=client,
52 | db_name=sys_db_name,
53 | auth=Auth(username=root, password=password),
54 | )
55 |
56 | assert connection.db_name == sys_db_name
57 | status_code = await connection.ping()
58 | assert status_code == 200
59 |
60 |
61 | @pytest.mark.asyncio
62 | async def test_BasicConnection_with_compression(
63 | client_session, url, sys_db_name, root, password
64 | ):
65 | client = AioHTTPClient()
66 | session = client_session(client, url)
67 | resolver = DefaultHostResolver(1)
68 | compression = DefaultCompressionManager(
69 | threshold=2, level=5, accept=AcceptEncoding.DEFLATE
70 | )
71 |
72 | connection = BasicConnection(
73 | sessions=[session],
74 | host_resolver=resolver,
75 | http_client=client,
76 | db_name=sys_db_name,
77 | auth=Auth(username=root, password=password),
78 | compression=compression,
79 | )
80 |
81 | data = b"a" * 100
82 | request = Request(method=Method.GET, endpoint="/_api/collection", data=data)
83 | _ = await connection.send_request(request)
84 | assert len(request.data) < len(data)
85 | assert zlib.decompress(request.data) == data
86 | assert request.headers["content-encoding"] == "deflate"
87 | assert request.headers["accept-encoding"] == "deflate"
88 |
89 |
90 | @pytest.mark.asyncio
91 | async def test_BasicConnection_prep_response_bad_response(
92 | client_session, url, sys_db_name
93 | ):
94 | client = AioHTTPClient()
95 | session = client_session(client, url)
96 | resolver = DefaultHostResolver(1)
97 |
98 | connection = BasicConnection(
99 | sessions=[session],
100 | host_resolver=resolver,
101 | http_client=client,
102 | db_name=sys_db_name,
103 | )
104 |
105 | request = Request(method=Method.GET, endpoint="/_api/collection")
106 | response = Response(Method.GET, url, {}, 0, "ERROR", b"")
107 | connection.prep_response(request, response)
108 | assert response.is_success is False
109 | with pytest.raises(ServerConnectionError):
110 | connection.raise_for_status(request, response)
111 |
112 | error = b'{"error": true, "errorMessage": "msg", "errorNum": 1234}'
113 | response = Response(Method.GET, url, {}, 0, "ERROR", error)
114 | connection.prep_response(request, response)
115 | assert response.error_code == 1234
116 | assert response.error_message == "msg"
117 |
118 |
119 | @pytest.mark.asyncio
120 | async def test_BasicConnection_process_request_connection_aborted(
121 | monkeypatch, client_session, url, sys_db_name, root, password
122 | ):
123 | client = AioHTTPClient()
124 | session = client_session(client, url)
125 | max_tries = 4
126 | resolver = DefaultHostResolver(1, max_tries=max_tries)
127 |
128 | request = Request(method=Method.GET, endpoint="/_api/collection")
129 |
130 | tries = 0
131 |
132 | async def mock_send_request(*args, **kwargs):
133 | nonlocal tries
134 | tries += 1
135 | raise ClientConnectionError("test")
136 |
137 | monkeypatch.setattr(client, "send_request", mock_send_request)
138 |
139 | connection = BasicConnection(
140 | sessions=[session],
141 | host_resolver=resolver,
142 | http_client=client,
143 | db_name=sys_db_name,
144 | auth=Auth(username=root, password=password),
145 | )
146 |
147 | with pytest.raises(ClientConnectionAbortedError):
148 | await connection.process_request(request)
149 | assert tries == max_tries
150 |
151 |
152 | @pytest.mark.asyncio
153 | async def test_JwtConnection_no_auth(client_session, url, sys_db_name):
154 | client = AioHTTPClient()
155 | session = client_session(client, url)
156 | resolver = DefaultHostResolver(1)
157 | with pytest.raises(ValueError):
158 | _ = JwtConnection(
159 | sessions=[session],
160 | host_resolver=resolver,
161 | http_client=client,
162 | db_name=sys_db_name,
163 | )
164 |
165 |
166 | @pytest.mark.asyncio
167 | async def test_JwtConnection_invalid_token(client_session, url, sys_db_name):
168 | client = AioHTTPClient()
169 | session = client_session(client, url)
170 | resolver = DefaultHostResolver(1)
171 |
172 | invalid_token = JwtToken.generate_token("invalid token")
173 | connection = JwtConnection(
174 | sessions=[session],
175 | host_resolver=resolver,
176 | http_client=client,
177 | db_name=sys_db_name,
178 | token=invalid_token,
179 | )
180 | with pytest.raises(ServerConnectionError):
181 | await connection.ping()
182 |
183 |
184 | @pytest.mark.asyncio
185 | async def test_JwtConnection_ping_success(
186 | client_session, url, sys_db_name, root, password
187 | ):
188 | client = AioHTTPClient()
189 | session = client_session(client, url)
190 | resolver = DefaultHostResolver(1)
191 |
192 | connection1 = JwtConnection(
193 | sessions=[session],
194 | host_resolver=resolver,
195 | http_client=client,
196 | db_name=sys_db_name,
197 | auth=Auth(username=root, password=password),
198 | )
199 | assert connection1.db_name == sys_db_name
200 | status_code = await connection1.ping()
201 | assert status_code == 200
202 |
203 | # Test reusing the token
204 | connection2 = JwtConnection(
205 | sessions=[session],
206 | host_resolver=resolver,
207 | http_client=client,
208 | db_name=sys_db_name,
209 | token=connection1.token,
210 | )
211 | assert connection2.db_name == sys_db_name
212 | status_code = await connection2.ping()
213 | assert status_code == 200
214 |
215 | connection3 = JwtConnection(
216 | sessions=[session],
217 | host_resolver=resolver,
218 | http_client=client,
219 | db_name=sys_db_name,
220 | auth=Auth(username=root, password=password),
221 | )
222 | connection3.token = connection1.token
223 | status_code = await connection1.ping()
224 | assert status_code == 200
225 |
226 |
227 | @pytest.mark.asyncio
228 | async def test_JwtSuperuserConnection_ping_success(
229 | client_session, url, sys_db_name, token
230 | ):
231 | client = AioHTTPClient()
232 | session = client_session(client, url)
233 | resolver = DefaultHostResolver(1)
234 |
235 | connection = JwtSuperuserConnection(
236 | sessions=[session],
237 | host_resolver=resolver,
238 | http_client=client,
239 | db_name=sys_db_name,
240 | token=token,
241 | )
242 | assert connection.db_name == sys_db_name
243 | status_code = await connection.ping()
244 | assert status_code == 200
245 |
246 |
247 | @pytest.mark.asyncio
248 | async def test_JwtSuperuserConnection_ping_failed(client_session, url, sys_db_name):
249 | client = AioHTTPClient()
250 | session = client_session(client, url)
251 | resolver = DefaultHostResolver(1)
252 |
253 | connection = JwtSuperuserConnection(
254 | sessions=[session],
255 | host_resolver=resolver,
256 | http_client=client,
257 | db_name=sys_db_name,
258 | token=JwtToken.generate_token("invalid token"),
259 | )
260 | with pytest.raises(ServerConnectionError):
261 | await connection.ping()
262 |
--------------------------------------------------------------------------------
/tests/test_database.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 |
3 | import pytest
4 |
5 | from arangoasync.collection import StandardCollection
6 | from arangoasync.exceptions import (
7 | CollectionCreateError,
8 | CollectionDeleteError,
9 | CollectionListError,
10 | DatabaseCreateError,
11 | DatabaseDeleteError,
12 | DatabaseListError,
13 | DatabasePropertiesError,
14 | JWTSecretListError,
15 | JWTSecretReloadError,
16 | ServerStatusError,
17 | ServerVersionError,
18 | )
19 | from arangoasync.typings import CollectionType, KeyOptions, UserInfo
20 | from tests.helpers import generate_col_name, generate_db_name, generate_username
21 |
22 |
23 | @pytest.mark.asyncio
24 | async def test_database_misc_methods(sys_db, db, bad_db, cluster):
25 | # Status
26 | status = await sys_db.status()
27 | assert status["server"] == "arango"
28 | with pytest.raises(ServerStatusError):
29 | await bad_db.status()
30 |
31 | sys_properties, db_properties = await asyncio.gather(
32 | sys_db.properties(), db.properties()
33 | )
34 | assert sys_properties.is_system is True
35 | assert db_properties.is_system is False
36 | assert sys_properties.name == sys_db.name
37 | assert db_properties.name == db.name
38 | if cluster:
39 | assert db_properties.replication_factor == 3
40 | assert db_properties.write_concern == 2
41 |
42 | with pytest.raises(DatabasePropertiesError):
43 | await bad_db.properties()
44 | assert len(db_properties.format()) > 1
45 |
46 | # JWT secrets
47 | with pytest.raises(JWTSecretListError):
48 | await bad_db.jwt_secrets()
49 | with pytest.raises(JWTSecretReloadError):
50 | await bad_db.reload_jwt_secrets()
51 |
52 | # Version
53 | version = await sys_db.version()
54 | assert version["version"].startswith("3.")
55 | with pytest.raises(ServerVersionError):
56 | await bad_db.version()
57 |
58 |
59 | @pytest.mark.asyncio
60 | async def test_create_drop_database(
61 | arango_client,
62 | sys_db,
63 | db,
64 | bad_db,
65 | basic_auth_root,
66 | password,
67 | cluster,
68 | ):
69 | # Create a new database
70 | db_name = generate_db_name()
71 | db_kwargs = dict(
72 | name=db_name,
73 | users=[
74 | dict(username=generate_username(), password=password, active=True),
75 | UserInfo(user=generate_username(), password=password, active=True),
76 | ],
77 | )
78 | if cluster:
79 | db_kwargs["replication_factor"] = 3
80 | db_kwargs["write_concern"] = 2
81 | db_kwargs["sharding"] = "flexible"
82 |
83 | assert await sys_db.create_database(**db_kwargs) is True
84 | await arango_client.db(
85 | db_name, auth_method="basic", auth=basic_auth_root, verify=True
86 | )
87 | assert await sys_db.has_database(db_name) is True
88 |
89 | # Try to create a database without permissions
90 | with pytest.raises(DatabaseCreateError):
91 | await db.create_database(generate_db_name())
92 |
93 | # Try to create a database that already exists
94 | with pytest.raises(DatabaseCreateError):
95 | await sys_db.create_database(db_name)
96 |
97 | # List available databases
98 | dbs = await sys_db.databases()
99 | assert db_name in dbs
100 | assert "_system" in dbs
101 | dbs = await sys_db.databases_accessible_to_user()
102 | assert db_name in dbs
103 | assert "_system" in dbs
104 | dbs = await db.databases_accessible_to_user()
105 | assert db.name in dbs
106 |
107 | # Cannot list databases without permission
108 | with pytest.raises(DatabaseListError):
109 | await db.databases()
110 | with pytest.raises(DatabaseListError):
111 | await db.has_database(db_name)
112 | with pytest.raises(DatabaseListError):
113 | await bad_db.databases_accessible_to_user()
114 |
115 | # Databases can only be dropped from the system database
116 | with pytest.raises(DatabaseDeleteError):
117 | await db.delete_database(db_name)
118 |
119 | # Drop the newly created database
120 | assert await sys_db.delete_database(db_name) is True
121 | non_existent_db = generate_db_name()
122 | assert await sys_db.has_database(non_existent_db) is False
123 | assert await sys_db.delete_database(non_existent_db, ignore_missing=True) is False
124 |
125 |
126 | @pytest.mark.asyncio
127 | async def test_create_drop_collection(db, bad_db, cluster):
128 | # Create a new collection
129 | col_name = generate_col_name()
130 | col = await db.create_collection(col_name)
131 | assert isinstance(col, StandardCollection)
132 | assert await db.has_collection(col_name)
133 | cols = await db.collections()
134 | assert any(c.name == col_name for c in cols)
135 |
136 | # Try to create a collection that already exists
137 | with pytest.raises(CollectionCreateError):
138 | await db.create_collection(col_name)
139 |
140 | # Try collection methods from a non-existent db
141 | with pytest.raises(CollectionCreateError):
142 | await bad_db.create_collection(generate_col_name())
143 | with pytest.raises(CollectionListError):
144 | await bad_db.collections()
145 | with pytest.raises(CollectionListError):
146 | await bad_db.has_collection(col_name)
147 |
148 | # Try to create a collection with invalid args
149 | with pytest.raises(ValueError):
150 | await db.create_collection(generate_col_name(), col_type="invalid")
151 | with pytest.raises(ValueError):
152 | await db.create_collection(generate_col_name(), col_type=db)
153 | with pytest.raises(ValueError):
154 | await db.create_collection(generate_col_name(), key_options={})
155 |
156 | # Drop the newly created collection
157 | assert await db.delete_collection(col_name) is True
158 | assert not await db.has_collection(col_name)
159 | non_existent_col = generate_col_name()
160 | assert await db.has_collection(non_existent_col) is False
161 | assert await db.delete_collection(non_existent_col, ignore_missing=True) is False
162 |
163 | # Do not ignore missing collection
164 | with pytest.raises(CollectionDeleteError):
165 | await db.delete_collection(non_existent_col)
166 |
167 | # Multiple arguments in a cluster setup
168 | if cluster:
169 | schema = {
170 | "rule": {
171 | "type": "object",
172 | "properties": {
173 | "test_attr:": {"type": "string"},
174 | },
175 | "required": ["test_attr"],
176 | },
177 | "level": "moderate",
178 | "message": "Schema Validation Failed.",
179 | "type": "json",
180 | }
181 |
182 | computed_values = [
183 | {
184 | "name": "foo",
185 | "expression": "RETURN 1",
186 | "computeOn": ["insert", "update", "replace"],
187 | "overwrite": True,
188 | "failOnWarning": False,
189 | "keepNull": True,
190 | }
191 | ]
192 |
193 | col = await db.create_collection(
194 | col_name,
195 | col_type=CollectionType.DOCUMENT,
196 | write_concern=2,
197 | wait_for_sync=True,
198 | number_of_shards=1,
199 | is_system=False,
200 | computed_values=computed_values,
201 | schema=schema,
202 | key_options=KeyOptions(
203 | allow_user_keys=True,
204 | generator_type="autoincrement",
205 | increment=5,
206 | offset=10,
207 | ),
208 | )
209 | assert col.name == col_name
210 | assert await db.delete_collection(col_name) is True
211 |
--------------------------------------------------------------------------------
/tests/test_http.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from arangoasync.auth import Auth
4 | from arangoasync.exceptions import ClientConnectionError
5 | from arangoasync.http import AioHTTPClient, DefaultHTTPClient
6 | from arangoasync.request import Method, Request
7 |
8 |
9 | def test_DefaultHTTPClient():
10 | # This test is here in case to prevent accidental changes to the DefaultHTTPClient.
11 | # Changed should be pushed only after the new HTTP client is covered by tests.
12 | assert DefaultHTTPClient == AioHTTPClient
13 |
14 |
15 | @pytest.mark.asyncio
16 | async def test_AioHTTPClient_wrong_url(client_session):
17 | client = AioHTTPClient()
18 | session = client_session(client, "http://localhost:0000")
19 | request = Request(
20 | method=Method.GET,
21 | endpoint="/_api/version",
22 | )
23 | with pytest.raises(ClientConnectionError):
24 | await client.send_request(session, request)
25 |
26 |
27 | @pytest.mark.asyncio
28 | async def test_AioHTTPClient_simple_request(client_session, url):
29 | client = AioHTTPClient()
30 | session = client_session(client, url)
31 | request = Request(
32 | method=Method.GET,
33 | endpoint="/_api/version",
34 | )
35 | response = await client.send_request(session, request)
36 | assert response.method == Method.GET
37 | assert response.url == f"{url}/_api/version"
38 | assert response.status_code == 401
39 | assert response.status_text == "Unauthorized"
40 |
41 |
42 | @pytest.mark.asyncio
43 | async def test_AioHTTPClient_auth_pass(client_session, url, root, password):
44 | client = AioHTTPClient()
45 | session = client_session(client, url)
46 | request = Request(
47 | method=Method.GET,
48 | endpoint="/_api/version",
49 | auth=Auth(username=root, password=password),
50 | )
51 | response = await client.send_request(session, request)
52 | assert response.method == Method.GET
53 | assert response.url == f"{url}/_api/version"
54 | assert response.status_code == 200
55 | assert response.status_text == "OK"
56 |
--------------------------------------------------------------------------------
/tests/test_resolver.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from arangoasync.resolver import (
4 | DefaultHostResolver,
5 | RoundRobinHostResolver,
6 | SingleHostResolver,
7 | get_resolver,
8 | )
9 |
10 |
11 | def test_get_resolver():
12 | resolver = get_resolver("default", 1, 2)
13 | assert isinstance(resolver, DefaultHostResolver)
14 |
15 | resolver = get_resolver("single", 2)
16 | assert isinstance(resolver, SingleHostResolver)
17 |
18 | resolver = get_resolver("roundrobin", 3)
19 | assert isinstance(resolver, RoundRobinHostResolver)
20 |
21 | with pytest.raises(ValueError):
22 | get_resolver("invalid", 1)
23 |
24 | with pytest.raises(ValueError):
25 | # max_tries cannot be less than host_count
26 | get_resolver("roundrobin", 3, 1)
27 |
28 |
29 | def test_SingleHostResolver():
30 | resolver = SingleHostResolver(1, 2)
31 | assert resolver.host_count == 1
32 | assert resolver.max_tries == 2
33 | assert resolver.get_host_index() == 0
34 | assert resolver.get_host_index() == 0
35 |
36 | resolver = SingleHostResolver(3)
37 | assert resolver.host_count == 3
38 | assert resolver.max_tries == 9
39 | assert resolver.get_host_index() == 0
40 | resolver.change_host()
41 | assert resolver.get_host_index() == 1
42 | resolver.change_host()
43 | assert resolver.get_host_index() == 2
44 | resolver.change_host()
45 | assert resolver.get_host_index() == 0
46 |
47 |
48 | def test_RoundRobinHostResolver():
49 | resolver = RoundRobinHostResolver(3)
50 | assert resolver.host_count == 3
51 | assert resolver.get_host_index() == 0
52 | assert resolver.get_host_index() == 1
53 | assert resolver.get_host_index() == 2
54 | assert resolver.get_host_index() == 0
55 | resolver.change_host()
56 | assert resolver.get_host_index() == 2
57 |
--------------------------------------------------------------------------------
/tests/test_transaction.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 |
3 | import pytest
4 |
5 | from arangoasync.database import TransactionDatabase
6 | from arangoasync.errno import BAD_PARAMETER, FORBIDDEN, TRANSACTION_NOT_FOUND
7 | from arangoasync.exceptions import (
8 | TransactionAbortError,
9 | TransactionCommitError,
10 | TransactionExecuteError,
11 | TransactionInitError,
12 | TransactionStatusError,
13 | )
14 |
15 |
16 | @pytest.mark.asyncio
17 | async def test_transaction_execute_raw(db, doc_col, docs):
18 | # Test a valid JS transaction
19 | doc = docs[0]
20 | key = doc["_key"]
21 | command = f"""
22 | function (params) {{
23 | var db = require('internal').db;
24 | db.{doc_col.name}.save({{'_key': params.key, 'val': 1}});
25 | return true;
26 | }}
27 | """ # noqa: E702 E231 E272 E202
28 | result = await db.execute_transaction(
29 | command=command,
30 | params={"key": key},
31 | write=[doc_col.name],
32 | read=[doc_col.name],
33 | exclusive=[doc_col.name],
34 | wait_for_sync=False,
35 | lock_timeout=1000,
36 | max_transaction_size=100000,
37 | allow_implicit=True,
38 | )
39 | assert result is True
40 | doc = await doc_col.get(key)
41 | assert doc is not None and doc["val"] == 1
42 |
43 | # Test an invalid transaction
44 | with pytest.raises(TransactionExecuteError) as err:
45 | await db.execute_transaction(command="INVALID COMMAND")
46 | assert err.value.error_code == BAD_PARAMETER
47 |
48 |
49 | @pytest.mark.asyncio
50 | async def test_transaction_document_insert(db, bad_db, doc_col, docs):
51 | # Start a basic transaction
52 | txn_db = await db.begin_transaction(
53 | read=doc_col.name,
54 | write=doc_col.name,
55 | exclusive=[],
56 | wait_for_sync=True,
57 | allow_implicit=False,
58 | lock_timeout=1000,
59 | max_transaction_size=1024 * 1024,
60 | skip_fast_lock_round=True,
61 | allow_dirty_read=False,
62 | )
63 |
64 | # Make sure the object properties are set correctly
65 | assert isinstance(txn_db, TransactionDatabase)
66 | assert txn_db.name == db.name
67 | assert txn_db.context == "transaction"
68 | assert txn_db.transaction_id is not None
69 | assert repr(txn_db) == f""
70 | txn_col = txn_db.collection(doc_col.name)
71 | assert txn_col.db_name == db.name
72 |
73 | with pytest.raises(TransactionInitError) as err:
74 | await bad_db.begin_transaction()
75 | assert err.value.error_code == FORBIDDEN
76 |
77 | # Insert a document in the transaction
78 | for doc in docs:
79 | result = await txn_col.insert(doc)
80 | assert result["_id"] == f"{doc_col.name}/{doc['_key']}"
81 | assert result["_key"] == doc["_key"]
82 | assert isinstance(result["_rev"], str)
83 | assert (await txn_col.get(doc["_key"]))["val"] == doc["val"]
84 |
85 | # Abort the transaction
86 | await txn_db.abort_transaction()
87 |
88 |
89 | @pytest.mark.asyncio
90 | async def test_transaction_status(db, doc_col):
91 | # Begin a transaction
92 | txn_db = await db.begin_transaction(read=doc_col.name)
93 | assert await txn_db.transaction_status() == "running"
94 |
95 | # Commit the transaction
96 | await txn_db.commit_transaction()
97 | assert await txn_db.transaction_status() == "committed"
98 |
99 | # Begin another transaction
100 | txn_db = await db.begin_transaction(read=doc_col.name)
101 | assert await txn_db.transaction_status() == "running"
102 |
103 | # Abort the transaction
104 | await txn_db.abort_transaction()
105 | assert await txn_db.transaction_status() == "aborted"
106 |
107 | # Test with an illegal transaction ID
108 | txn_db = db.fetch_transaction("illegal")
109 | with pytest.raises(TransactionStatusError) as err:
110 | await txn_db.transaction_status()
111 | # Error code differs between single server and cluster mode
112 | assert err.value.error_code in {BAD_PARAMETER, TRANSACTION_NOT_FOUND}
113 |
114 |
115 | @pytest.mark.asyncio
116 | async def test_transaction_commit(db, doc_col, docs):
117 | # Begin a transaction
118 | txn_db = await db.begin_transaction(
119 | read=doc_col.name,
120 | write=doc_col.name,
121 | )
122 | txn_col = txn_db.collection(doc_col.name)
123 |
124 | # Insert documents in the transaction
125 | assert "_rev" in await txn_col.insert(docs[0])
126 | assert "_rev" in await txn_col.insert(docs[2])
127 | await txn_db.commit_transaction()
128 | assert await txn_db.transaction_status() == "committed"
129 |
130 | # Check the documents, after transaction has been committed
131 | doc = await doc_col.get(docs[2]["_key"])
132 | assert doc["_key"] == docs[2]["_key"]
133 | assert doc["val"] == docs[2]["val"]
134 |
135 | # Test with an illegal transaction ID
136 | txn_db = db.fetch_transaction("illegal")
137 | with pytest.raises(TransactionCommitError) as err:
138 | await txn_db.commit_transaction()
139 | # Error code differs between single server and cluster mode
140 | assert err.value.error_code in {BAD_PARAMETER, TRANSACTION_NOT_FOUND}
141 |
142 |
143 | @pytest.mark.asyncio
144 | async def test_transaction_abort(db, doc_col, docs):
145 | # Begin a transaction
146 | txn_db = await db.begin_transaction(
147 | read=doc_col.name,
148 | write=doc_col.name,
149 | )
150 | txn_col = txn_db.collection(doc_col.name)
151 |
152 | # Insert documents in the transaction
153 | assert "_rev" in await txn_col.insert(docs[0])
154 | assert "_rev" in await txn_col.insert(docs[2])
155 | await txn_db.abort_transaction()
156 | assert await txn_db.transaction_status() == "aborted"
157 |
158 | # Check the documents, after transaction has been aborted
159 | assert await doc_col.get(docs[2]["_key"]) is None
160 |
161 | # Test with an illegal transaction ID
162 | txn_db = db.fetch_transaction("illegal")
163 | with pytest.raises(TransactionAbortError) as err:
164 | await txn_db.abort_transaction()
165 | # Error code differs between single server and cluster mode
166 | assert err.value.error_code in {BAD_PARAMETER, TRANSACTION_NOT_FOUND}
167 |
168 |
169 | @pytest.mark.asyncio
170 | async def test_transaction_fetch_existing(db, doc_col, docs):
171 | # Begin a transaction
172 | txn_db = await db.begin_transaction(
173 | read=doc_col.name,
174 | write=doc_col.name,
175 | )
176 | txn_col = txn_db.collection(doc_col.name)
177 |
178 | # Insert documents in the transaction
179 | assert "_rev" in await txn_col.insert(docs[0])
180 | assert "_rev" in await txn_col.insert(docs[1])
181 |
182 | txn_db2 = db.fetch_transaction(txn_db.transaction_id)
183 | assert txn_db2.transaction_id == txn_db.transaction_id
184 | txn_col2 = txn_db2.collection(doc_col.name)
185 | assert "_rev" in await txn_col2.insert(docs[2])
186 |
187 | await txn_db2.commit_transaction()
188 | assert await txn_db.transaction_status() == "committed"
189 | assert await txn_db2.transaction_status() == "committed"
190 |
191 | # Check the documents, after transaction has been aborted
192 | assert all(
193 | await asyncio.gather(*(doc_col.get(docs[idx]["_key"]) for idx in range(3)))
194 | )
195 |
196 |
197 | @pytest.mark.asyncio
198 | async def test_transaction_list(db):
199 | # There should be no transactions initially
200 | assert await db.list_transactions() == []
201 |
202 | # Begin a transaction
203 | txn_db1 = await db.begin_transaction()
204 | tx_ls = await db.list_transactions()
205 | assert len(tx_ls) == 1
206 | assert any(txn_db1.transaction_id == tx["id"] for tx in tx_ls)
207 |
208 | # Begin another transaction
209 | txn_db2 = await db.begin_transaction()
210 | tx_ls = await db.list_transactions()
211 | assert len(tx_ls) == 2
212 | assert any(txn_db1.transaction_id == tx["id"] for tx in tx_ls)
213 | assert any(txn_db2.transaction_id == tx["id"] for tx in tx_ls)
214 |
215 | # Only the first transaction should be running after aborting the second
216 | await txn_db2.abort_transaction()
217 | tx_ls = await db.list_transactions()
218 | assert len(tx_ls) == 1
219 | assert any(txn_db1.transaction_id == tx["id"] for tx in tx_ls)
220 |
221 | # Commit the first transaction, no transactions should be left
222 | await txn_db1.commit_transaction()
223 | tx_ls = await db.list_transactions()
224 | assert len(tx_ls) == 0
225 |
--------------------------------------------------------------------------------
/tests/test_user.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from arangoasync.auth import Auth
4 | from arangoasync.errno import USER_NOT_FOUND
5 | from arangoasync.exceptions import (
6 | CollectionCreateError,
7 | DocumentInsertError,
8 | PermissionResetError,
9 | PermissionUpdateError,
10 | UserCreateError,
11 | UserDeleteError,
12 | UserGetError,
13 | UserListError,
14 | UserReplaceError,
15 | UserUpdateError,
16 | )
17 | from arangoasync.typings import UserInfo
18 | from tests.helpers import generate_col_name, generate_string, generate_username
19 |
20 |
21 | @pytest.mark.asyncio
22 | async def test_user_management(sys_db, db, bad_db):
23 | # Create a user
24 | username = generate_username()
25 | password = generate_string()
26 | users = await sys_db.users()
27 | assert not any(user.user == username for user in users)
28 | assert await sys_db.has_user(username) is False
29 |
30 | # Should not be able to create a user without permission
31 | with pytest.raises(UserCreateError):
32 | await db.create_user(
33 | UserInfo(
34 | user=username,
35 | password=password,
36 | active=True,
37 | extra={"foo": "bar"},
38 | )
39 | )
40 |
41 | # Verify user creation
42 | new_user = await sys_db.create_user(
43 | UserInfo(
44 | user=username,
45 | password=password,
46 | active=True,
47 | extra={"foo": "bar"},
48 | )
49 | )
50 | assert new_user.user == username
51 | assert new_user.active is True
52 | assert new_user.extra == {"foo": "bar"}
53 | users = await sys_db.users()
54 | assert sum(user.user == username for user in users) == 1
55 | assert await sys_db.has_user(username) is True
56 | user = await sys_db.user(username)
57 | assert user.user == username
58 | assert user.active is True
59 |
60 | # Get non-existing user
61 | with pytest.raises(UserGetError):
62 | await sys_db.user(generate_username())
63 |
64 | # Create already existing user
65 | with pytest.raises(UserCreateError):
66 | await sys_db.create_user(
67 | UserInfo(
68 | user=username,
69 | password=password,
70 | active=True,
71 | extra={"foo": "bar"},
72 | )
73 | )
74 |
75 | # Update existing user
76 | new_user = await sys_db.update_user(
77 | UserInfo(
78 | user=username,
79 | password=password,
80 | active=False,
81 | extra={"bar": "baz"},
82 | )
83 | )
84 | assert new_user["user"] == username
85 | assert new_user["active"] is False
86 | assert new_user["extra"] == {"foo": "bar", "bar": "baz"}
87 | assert await sys_db.user(username) == new_user
88 |
89 | # Update missing user
90 | with pytest.raises(UserUpdateError) as err:
91 | await sys_db.update_user(
92 | UserInfo(user=generate_username(), password=generate_string())
93 | )
94 | assert err.value.error_code == USER_NOT_FOUND
95 |
96 | # Replace existing user
97 | new_user = await sys_db.replace_user(
98 | UserInfo(
99 | user=username,
100 | password=password,
101 | active=True,
102 | extra={"baz": "qux"},
103 | )
104 | )
105 | assert new_user["user"] == username
106 | assert new_user["active"] is True
107 | assert new_user["extra"] == {"baz": "qux"}
108 | assert await sys_db.user(username) == new_user
109 |
110 | # Replace missing user
111 | with pytest.raises(UserReplaceError) as err:
112 | await sys_db.replace_user(
113 | {"user": generate_username(), "password": generate_string()}
114 | )
115 | assert err.value.error_code == USER_NOT_FOUND
116 |
117 | # Delete the newly created user
118 | assert await sys_db.delete_user(username) is True
119 | users = await sys_db.users()
120 | assert not any(user.user == username for user in users)
121 | assert await sys_db.has_user(username) is False
122 |
123 | # Ignore missing user
124 | assert await sys_db.delete_user(username, ignore_missing=True) is False
125 |
126 | # Cannot delete user without permission
127 | with pytest.raises(UserDeleteError):
128 | await db.delete_user(username)
129 |
130 | # Cannot list users with a non-existing database
131 | with pytest.raises(UserListError):
132 | await bad_db.users()
133 | with pytest.raises(UserListError):
134 | await bad_db.has_user(username)
135 |
136 |
137 | @pytest.mark.asyncio
138 | async def test_user_change_permissions(sys_db, arango_client, db):
139 | username = generate_username()
140 | password = generate_string()
141 | auth = Auth(username, password)
142 |
143 | # Set read-only permissions
144 | await sys_db.create_user(UserInfo(username, password))
145 |
146 | # Should not be able to update permissions without permission
147 | with pytest.raises(PermissionUpdateError):
148 | await db.update_permission(username, "ro", db.name)
149 |
150 | await sys_db.update_permission(username, "ro", db.name)
151 |
152 | # Verify read-only permissions
153 | permission = await sys_db.permission(username, db.name)
154 | assert permission == "ro"
155 |
156 | # Should not be able to create a collection
157 | col_name = generate_col_name()
158 | db2 = await arango_client.db(db.name, auth=auth, verify=True)
159 | with pytest.raises(CollectionCreateError):
160 | await db2.create_collection(col_name)
161 |
162 | all_permissions = await sys_db.permissions(username)
163 | assert "_system" in all_permissions
164 | assert db.name in all_permissions
165 | all_permissions = await sys_db.permissions(username, full=False)
166 | assert all_permissions[db.name] == "ro"
167 |
168 | # Set read-write permissions
169 | await sys_db.update_permission(username, "rw", db.name)
170 |
171 | # Should be able to create collection
172 | col = await db2.create_collection(col_name)
173 | await col.insert({"_key": "test"})
174 |
175 | # Reset permissions
176 | with pytest.raises(PermissionResetError):
177 | await db.reset_permission(username, db.name)
178 | await sys_db.reset_permission(username, db.name)
179 | with pytest.raises(DocumentInsertError):
180 | await col.insert({"_key": "test"})
181 |
182 | # Allow rw access
183 | await sys_db.update_permission(username, "rw", db.name)
184 | await col.insert({"_key": "test2"})
185 |
186 | # No access to collection
187 | await sys_db.update_permission(username, "none", db.name, col_name)
188 | with pytest.raises(DocumentInsertError):
189 | await col.insert({"_key": "test"})
190 |
191 | await db.delete_collection(col_name)
192 |
--------------------------------------------------------------------------------