├── .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 | ![Logo](https://raw.githubusercontent.com/arangodb/python-arango-async/refs/heads/main/docs/static/logo.png) 2 | 3 | [![CircleCI](https://dl.circleci.com/status-badge/img/gh/arangodb/python-arango-async/tree/main.svg?style=svg)](https://dl.circleci.com/status-badge/redirect/gh/arangodb/python-arango-async/tree/main) 4 | [![CodeQL](https://github.com/arangodb/python-arango-async/actions/workflows/codeql.yaml/badge.svg)](https://github.com/arangodb/python-arango-async/actions/workflows/codeql.yaml) 5 | [![Last commit](https://img.shields.io/github/last-commit/arangodb/python-arango-async)](https://github.com/arangodb/python-arango-async/commits/main) 6 | 7 | [![PyPI version badge](https://img.shields.io/pypi/v/python-arango-async?color=3775A9&style=for-the-badge&logo=pypi&logoColor=FFD43B)](https://pypi.org/project/python-arango-async/) 8 | [![Python versions badge](https://img.shields.io/badge/3.10%2B-3776AB?style=for-the-badge&logo=python&logoColor=FFD43B&label=Python)](https://pypi.org/project/python-arango-async/) 9 | 10 | [![License](https://img.shields.io/github/license/arangodb/python-arango?color=9E2165&style=for-the-badge)](https://github.com/arangodb/python-arango/blob/main/LICENSE) 11 | [![Code style: black](https://img.shields.io/static/v1?style=for-the-badge&label=code%20style&message=black&color=black)](https://github.com/psf/black) 12 | [![Downloads](https://img.shields.io/pepy/dt/python-arango-async?style=for-the-badge&color=282661 13 | )](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 | --------------------------------------------------------------------------------