diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4ee473a..ae48c5f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,7 @@ repos: - id: trailing-whitespace - repo: https://github.com/psf/black - rev: 23.1.0 + rev: 24.4.2 hooks: - id: black @@ -29,12 +29,12 @@ repos: args: [ --profile, black ] - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 + rev: 7.0.0 hooks: - id: flake8 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.991 + rev: v1.10.0 hooks: - id: mypy files: ^arangoasync/ diff --git a/arangoasync/__init__.py b/arangoasync/__init__.py index e69de29..58f3ace 100644 --- a/arangoasync/__init__.py +++ b/arangoasync/__init__.py @@ -0,0 +1 @@ +from .version import __version__ diff --git a/arangoasync/http.py b/arangoasync/http.py new file mode 100644 index 0000000..75df659 --- /dev/null +++ b/arangoasync/http.py @@ -0,0 +1,152 @@ +__all__ = [ + "HTTPClient", + "AioHTTPClient", + "DefaultHTTPClient", +] + +from abc import ABC, abstractmethod +from typing import Any, Optional + +from aiohttp import BaseConnector, BasicAuth, ClientSession, ClientTimeout, TCPConnector + +from arangoasync.request import Request +from arangoasync.response import Response + + +class HTTPClient(ABC): # pragma: no cover + """Abstract base class for HTTP clients. + Custom HTTP clients should inherit from this class. + """ + + @abstractmethod + def create_session(self, host: str) -> Any: + """Return a new session given the base host URL. + + This method must be overridden by the user. + + :param host: ArangoDB host URL. + :type host: str + :returns: Requests session object. + :rtype: Any + """ + raise NotImplementedError + + @abstractmethod + async def send_request( + self, + session: Any, + request: Request, + ) -> Response: + """Send an HTTP request. + + This method must be overridden by the user. + + :param session: Session object. + :type session: Any + :param request: HTTP request. + :type request: arangoasync.request.Request + :returns: HTTP response. + :rtype: arangoasync.response.Response + """ + raise NotImplementedError + + +class AioHTTPClient(HTTPClient): + """HTTP client implemented on top of [aiohttp](https://docs.aiohttp.org/en/stable/). + + :param connector: Supports connection pooling. + By default, 100 simultaneous connections are supported, with a 60-second timeout + for connection reusing after release. + :type connector: aiohttp.BaseConnector | None + :param timeout: Timeout settings. + 300s total timeout by default for a complete request/response operation. + :type timeout: aiohttp.ClientTimeout | None + :param read_bufsize: Size of read buffer (64KB default). + :type read_bufsize: int + :param auth: HTTP authentication helper. + Should be used for specifying authorization data in client API. + :type auth: aiohttp.BasicAuth | None + :param compression_threshold: Will compress requests to the server if + the size of the request body (in bytes) is at least the value of this + option. + :type compression_threshold: int + """ + + def __init__( + self, + connector: Optional[BaseConnector] = None, + timeout: Optional[ClientTimeout] = None, + read_bufsize: int = 2**16, + auth: Optional[BasicAuth] = None, + compression_threshold: int = 1024, + ) -> None: + self._connector = connector or TCPConnector( + keepalive_timeout=60, # timeout for connection reusing after releasing + limit=100, # total number simultaneous connections + ) + self._timeout = timeout or ClientTimeout( + total=300, # total number of seconds for the whole request + connect=60, # max number of seconds for acquiring a pool connection + ) + self._read_bufsize = read_bufsize + self._auth = auth + self._compression_threshold = compression_threshold + + def create_session(self, host: str) -> ClientSession: + """Return a new session given the base host URL. + + :param host: ArangoDB host URL. Typically, the address and port of a coordinator + (e.g. "http://127.0.0.1:8529"). + :type host: str + :returns: Session object. + :rtype: aiohttp.ClientSession + """ + return ClientSession( + base_url=host, + connector=self._connector, + timeout=self._timeout, + auth=self._auth, + read_bufsize=self._read_bufsize, + ) + + async def send_request( + self, + session: ClientSession, + request: Request, + ) -> Response: + """Send an HTTP request. + + :param session: Session object. + :type session: aiohttp.ClientSession + :param request: HTTP request. + :type request: arangoasync.request.Request + :returns: HTTP response. + :rtype: arangoasync.response.Response + """ + method = request.method + endpoint = request.endpoint + headers = request.headers + params = request.params + data = request.data + compress = data is not None and len(data) >= self._compression_threshold + + async with session.request( + method.name, + endpoint, + headers=headers, + params=params, + data=data, + compress=compress, + ) as response: + raw_body = await response.read() + return Response( + method=method, + url=str(response.real_url), + headers=response.headers, + status_code=response.status, + status_text=response.reason, + raw_body=raw_body, + ) + + +DefaultHTTPClient = AioHTTPClient diff --git a/arangoasync/request.py b/arangoasync/request.py new file mode 100644 index 0000000..a6d7396 --- /dev/null +++ b/arangoasync/request.py @@ -0,0 +1,119 @@ +__all__ = [ + "Method", + "Request", +] + +from enum import Enum, auto +from typing import Optional + +from arangoasync.typings import Headers, Params +from arangoasync.version import __version__ + + +class Method(Enum): + """HTTP methods.""" + + GET = auto() + POST = auto() + PUT = auto() + PATCH = auto() + DELETE = auto() + HEAD = auto() + OPTIONS = auto() + + +class Request: + """HTTP request. + + :param method: HTTP method. + :type method: request.Method + :param endpoint: API endpoint. + :type endpoint: str + :param headers: Request headers. + :type headers: dict | None + :param params: URL parameters. + :type params: dict | None + :param data: Request payload. + :type data: Any + :param deserialize: Whether the response body should be deserialized. + :type deserialize: bool + + :ivar method: HTTP method. + :vartype method: request.Method + :ivar endpoint: API endpoint, for example "_api/version". + :vartype endpoint: str + :ivar headers: Request headers. + :vartype headers: dict | None + :ivar params: URL (query) parameters. + :vartype params: dict | None + :ivar data: Request payload. + :vartype data: Any + :ivar deserialize: Whether the response body should be deserialized. + :vartype deserialize: bool + """ + + __slots__ = ( + "method", + "endpoint", + "headers", + "params", + "data", + "deserialize", + ) + + def __init__( + self, + method: Method, + endpoint: str, + headers: Optional[Headers] = None, + params: Optional[Params] = None, + data: Optional[str] = None, + deserialize: bool = True, + ) -> None: + self.method: Method = method + self.endpoint: str = endpoint + self.headers: Headers = self._normalize_headers(headers) + self.params: Params = self._normalize_params(params) + self.data: Optional[str] = data + self.deserialize: bool = deserialize + + @staticmethod + def _normalize_headers(headers: Optional[Headers]) -> Headers: + """Normalize request headers. + + :param headers: Request headers. + :type headers: dict | None + :returns: Normalized request headers. + :rtype: dict + """ + driver_header = f"arangoasync/{__version__}" + normalized_headers: Headers = { + "charset": "utf-8", + "content-type": "application/json", + "x-arango-driver": driver_header, + } + + if headers is not None: + for key, value in headers.items(): + normalized_headers[key.lower()] = value + + return normalized_headers + + @staticmethod + def _normalize_params(params: Optional[Params]) -> Params: + """Normalize URL parameters. + + :param params: URL parameters. + :type params: dict | None + :returns: Normalized URL parameters. + :rtype: dict + """ + normalized_params: Params = {} + + if params is not None: + for key, value in params.items(): + if isinstance(value, bool): + value = int(value) + normalized_params[key] = str(value) + + return normalized_params diff --git a/arangoasync/response.py b/arangoasync/response.py new file mode 100644 index 0000000..85ace65 --- /dev/null +++ b/arangoasync/response.py @@ -0,0 +1,82 @@ +__all__ = [ + "Response", +] + +from typing import Optional + +from arangoasync.request import Method +from arangoasync.typings import Headers + + +class Response: + """HTTP response. + + :param method: HTTP method. + :type method: request.Method + :param url: API URL. + :type url: str + :param headers: Response headers. + :type headers: dict | None + :param status_code: Response status code. + :type status_code: int + :param status_text: Response status text. + :type status_text: str + :param raw_body: Raw response body. + :type raw_body: str + + :ivar method: HTTP method. + :vartype method: request.Method + :ivar url: API URL. + :vartype url: str + :ivar headers: Response headers. + :vartype headers: dict | None + :ivar status_code: Response status code. + :vartype status_code: int + :ivar status_text: Response status text. + :vartype status_text: str + :ivar raw_body: Raw response body. + :vartype raw_body: str + :ivar body: Response body after processing. + :vartype body: Any + :ivar error_code: Error code from ArangoDB server. + :vartype error_code: int + :ivar error_message: Error message from ArangoDB server. + :vartype error_message: str + :ivar is_success: True if response status code was 2XX. + :vartype is_success: bool + """ + + __slots__ = ( + "method", + "url", + "headers", + "status_code", + "status_text", + "body", + "raw_body", + "error_code", + "error_message", + "is_success", + ) + + def __init__( + self, + method: Method, + url: str, + headers: Headers, + status_code: int, + status_text: str, + raw_body: bytes, + ) -> None: + self.method: Method = method + self.url: str = url + self.headers: Headers = headers + self.status_code: int = status_code + self.status_text: str = status_text + self.raw_body: bytes = raw_body + + # Populated later + self.body: Optional[str] = None + self.error_code: Optional[int] = None + self.error_message: Optional[str] = None + self.is_success: Optional[bool] = None diff --git a/arangoasync/typings.py b/arangoasync/typings.py new file mode 100644 index 0000000..a96bbac --- /dev/null +++ b/arangoasync/typings.py @@ -0,0 +1,14 @@ +__all__ = [ + "Headers", + "Params", +] + +from typing import MutableMapping + +from multidict import MultiDict + +Headers = MutableMapping[str, str] | MultiDict[str] +Headers.__doc__ = """Type definition for HTTP headers""" + +Params = MutableMapping[str, bool | int | str] +Params.__doc__ = """Type definition for URL (query) parameters""" diff --git a/arangoasync/version.py b/arangoasync/version.py new file mode 100644 index 0000000..f102a9c --- /dev/null +++ b/arangoasync/version.py @@ -0,0 +1 @@ +__version__ = "0.0.1" diff --git a/docs/conf.py b/docs/conf.py index 1741ef9..159264c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,3 +1,9 @@ +import os +import sys + +# Required for autodoc +sys.path.insert(0, os.path.abspath("..")) + project = "python-arango-async" copyright_notice = "ArangoDB" author = "Alexandru Petenchea, Anthony Mahanna" @@ -6,6 +12,14 @@ "sphinx.ext.autodoc", "sphinx.ext.doctest", "sphinx.ext.viewcode", + "sphinx.ext.intersphinx", ] exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] +html_theme = "sphinx_rtd_theme" master_doc = "index" + +autodoc_member_order = "bysource" + +intersphinx_mapping = { + "aiohttp": ("https://docs.aiohttp.org/en/stable/", None), +} diff --git a/docs/index.rst b/docs/index.rst index dbcb93a..2419da8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -5,4 +5,11 @@ Welcome to the documentation for **python-arango-async**, a Python driver for Ar **The driver is currently work in progress and not yet ready for use.** +Development + +.. toctree:: + :maxdepth: 1 + + specs + .. _ArangoDB: https://www.arangodb.com diff --git a/docs/specs.rst b/docs/specs.rst new file mode 100644 index 0000000..6645d64 --- /dev/null +++ b/docs/specs.rst @@ -0,0 +1,53 @@ +API Specification +----------------- + +This page contains the specification for all classes and methods available in +python-arango-async. + +.. _AioHTTPClient: + +AioHTTPClient +================= + +.. autoclass:: arangoasync.http.AioHTTPClient + :members: + +.. _DefaultHTTPClient: + +DefaultHTTPClient +================= + +.. autoclass:: arangoasync.http.DefaultHTTPClient + :members: + +.. _HTTPClient: + +HTTPClient +========== + +.. autoclass:: arangoasync.http.HTTPClient + :members: + +.. _Method: + +Method +======= + +.. autoclass:: arangoasync.request.Method + :members: + +.. _Request: + +Request +======= + +.. autoclass:: arangoasync.request.Request + :members: + +.. _Response: + +Response +======== + +.. autoclass:: arangoasync.response.Response + :members: diff --git a/pyproject.toml b/pyproject.toml index 326c145..da47073 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,18 +39,24 @@ classifiers = [ dependencies = [ "packaging>=23.1", "setuptools>=42", + "aiohttp>=3.9", + "multidict>=6.0", ] +[tool.setuptools.dynamic] +version = { attr = "arangoasync.__version__" } + [project.optional-dependencies] dev = [ - "black>=22.3.0", - "flake8>=4.0.1", - "isort>=5.10.1", - "mypy>=0.942", - "pre-commit>=2.17.0", - "pytest>=7.1.1", - "pytest-cov>=3.0.0", - "sphinx", + "black>=24.2", + "flake8>=7.0", + "isort>=5.10", + "mypy>=1.10", + "pre-commit>=3.7", + "pytest>=8.2", + "pytest-asyncio>=0.23.8", + "pytest-cov>=5.0", + "sphinx>=7.3", "sphinx_rtd_theme", "types-setuptools", ] @@ -59,7 +65,7 @@ dev = [ "arangoasync" = ["py.typed"] [project.urls] -homepage = "https://github.com/arangodb/python-arango" +homepage = "https://github.com/arangodb/python-arango-async" [tool.setuptools] packages = ["arangoasync"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9edb2a3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,53 @@ +from dataclasses import dataclass + +import pytest + + +@dataclass +class GlobalData: + url: str = None + root: str = None + password: str = None + + +global_data = GlobalData() + + +def pytest_addoption(parser): + parser.addoption( + "--host", action="store", default="127.0.0.1", help="ArangoDB host address" + ) + parser.addoption( + "--port", action="append", default=["8529"], help="ArangoDB coordinator ports" + ) + parser.addoption( + "--root", action="store", default="root", help="ArangoDB root user" + ) + parser.addoption( + "--password", action="store", default="passwd", help="ArangoDB password" + ) + + +def pytest_configure(config): + ports = config.getoption("port") + hosts = [f"http://{config.getoption('host')}:{p}" for p in ports] + url = hosts[0] + + global_data.url = url + global_data.root = config.getoption("root") + global_data.password = config.getoption("password") + + +@pytest.fixture(autouse=False) +def url(): + return global_data.url + + +@pytest.fixture(autouse=False) +def root(): + return global_data.root + + +@pytest.fixture(autouse=False) +def password(): + return global_data.password diff --git a/tests/test_http.py b/tests/test_http.py new file mode 100644 index 0000000..a214bd1 --- /dev/null +++ b/tests/test_http.py @@ -0,0 +1,37 @@ +import pytest +from aiohttp import BasicAuth + +from arangoasync.http import AioHTTPClient +from arangoasync.request import Method, Request + + +@pytest.mark.asyncio +async def test_AioHTTPClient_simple_request(url): + client = AioHTTPClient() + session = client.create_session(url) + request = Request( + method=Method.GET, + endpoint="/_api/version", + deserialize=False, + ) + response = await client.send_request(session, request) + assert response.method == Method.GET + assert response.url == f"{url}/_api/version" + assert response.status_code == 401 + assert response.status_text == "Unauthorized" + + +@pytest.mark.asyncio +async def test_AioHTTPClient_auth_pass(url, root, password): + client = AioHTTPClient(auth=BasicAuth(root, password)) + session = client.create_session(url) + request = Request( + method=Method.GET, + endpoint="/_api/version", + deserialize=False, + ) + response = await client.send_request(session, request) + assert response.method == Method.GET + assert response.url == f"{url}/_api/version" + assert response.status_code == 200 + assert response.status_text == "OK"