python-arango-async 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
arangoasync/http.py ADDED
@@ -0,0 +1,182 @@
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 send_request(self, session, request):
37
+ pass
38
+ """
39
+
40
+ @abstractmethod
41
+ def create_session(self, host: str) -> Any:
42
+ """Return a new session given the base host URL.
43
+
44
+ Note:
45
+ This method must be overridden by the user.
46
+
47
+ Args:
48
+ host (str): ArangoDB host URL.
49
+
50
+ Returns:
51
+ Requests session object.
52
+ """
53
+ raise NotImplementedError
54
+
55
+ @abstractmethod
56
+ async def send_request(
57
+ self,
58
+ session: Any,
59
+ request: Request,
60
+ ) -> Response:
61
+ """Send an HTTP request.
62
+
63
+ Note:
64
+ This method must be overridden by the user.
65
+
66
+ Args:
67
+ session (Any): Client session object.
68
+ request (Request): HTTP request.
69
+
70
+ Returns:
71
+ Response: HTTP response.
72
+ """
73
+ raise NotImplementedError
74
+
75
+
76
+ class AioHTTPClient(HTTPClient):
77
+ """HTTP client implemented on top of aiohttp_.
78
+
79
+ Args:
80
+ connector (aiohttp.BaseConnector | None): Supports connection pooling.
81
+ By default, 100 simultaneous connections are supported, with a 60-second
82
+ timeout for connection reusing after release.
83
+ timeout (aiohttp.ClientTimeout | None): Client timeout settings.
84
+ 300s total timeout by default for a complete request/response operation.
85
+ read_bufsize (int): Size of read buffer (64KB default).
86
+ ssl_context (ssl.SSLContext | bool): SSL validation mode.
87
+ `True` for default SSL checks (see :func:`ssl.create_default_context`).
88
+ `False` disables SSL checks.
89
+ Additionally, you can pass a custom :class:`ssl.SSLContext`.
90
+
91
+ .. _aiohttp:
92
+ https://docs.aiohttp.org/en/stable/
93
+ """
94
+
95
+ def __init__(
96
+ self,
97
+ connector: Optional[BaseConnector] = None,
98
+ timeout: Optional[ClientTimeout] = None,
99
+ read_bufsize: int = 2**16,
100
+ ssl_context: bool | SSLContext = True,
101
+ ) -> None:
102
+ self._connector = connector or TCPConnector(
103
+ keepalive_timeout=60, # timeout for connection reusing after releasing
104
+ limit=100, # total number simultaneous connections
105
+ )
106
+ self._timeout = timeout or ClientTimeout(
107
+ total=300, # total number of seconds for the whole request
108
+ connect=60, # max number of seconds for acquiring a pool connection
109
+ )
110
+ self._read_bufsize = read_bufsize
111
+ self._ssl_context = (
112
+ ssl_context if ssl_context is not True else create_default_context()
113
+ )
114
+
115
+ def create_session(self, host: str) -> ClientSession:
116
+ """Return a new session given the base host URL.
117
+
118
+ Args:
119
+ host (str): ArangoDB host URL. Must not include any paths. Typically, this
120
+ is the address and port of a coordinator (e.g. "http://127.0.0.1:8529").
121
+
122
+ Returns:
123
+ aiohttp.ClientSession: Session object, used to send future requests.
124
+ """
125
+ return ClientSession(
126
+ base_url=host,
127
+ connector=self._connector,
128
+ timeout=self._timeout,
129
+ read_bufsize=self._read_bufsize,
130
+ )
131
+
132
+ async def send_request(
133
+ self,
134
+ session: ClientSession,
135
+ request: Request,
136
+ ) -> Response:
137
+ """Send an HTTP request.
138
+
139
+ Args:
140
+ session (aiohttp.ClientSession): Session object used to make the request.
141
+ request (Request): HTTP request.
142
+
143
+ Returns:
144
+ Response: HTTP response.
145
+
146
+ Raises:
147
+ ClientConnectionError: If the request fails.
148
+ """
149
+
150
+ if request.auth is not None:
151
+ auth = BasicAuth(
152
+ login=request.auth.username,
153
+ password=request.auth.password,
154
+ encoding=request.auth.encoding,
155
+ )
156
+ else:
157
+ auth = None
158
+
159
+ try:
160
+ async with session.request(
161
+ request.method.name,
162
+ request.endpoint,
163
+ headers=request.normalized_headers(),
164
+ params=request.normalized_params(),
165
+ data=request.data,
166
+ auth=auth,
167
+ ssl=self._ssl_context,
168
+ ) as response:
169
+ raw_body = await response.read()
170
+ return Response(
171
+ method=request.method,
172
+ url=str(response.real_url),
173
+ headers=response.headers,
174
+ status_code=response.status,
175
+ status_text=str(response.reason),
176
+ raw_body=raw_body,
177
+ )
178
+ except client_exceptions.ClientConnectionError as e:
179
+ raise ClientConnectionError(str(e)) from e
180
+
181
+
182
+ DefaultHTTPClient = AioHTTPClient
arangoasync/job.py ADDED
@@ -0,0 +1,214 @@
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 <https://docs.arangodb.com/stable/develop/http-api/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"<AsyncJob {self._id}>"
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 <https://docs.arangodb.com/stable/develop/http-api/jobs/#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 <https://docs.arangodb.com/stable/develop/http-api/jobs/#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 <https://docs.arangodb.com/stable/develop/http-api/jobs/#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 <https://docs.arangodb.com/stable/develop/http-api/jobs/#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"
arangoasync/logger.py ADDED
@@ -0,0 +1,3 @@
1
+ import logging
2
+
3
+ logger = logging.getLogger("arangoasync")
arangoasync/request.py ADDED
@@ -0,0 +1,107 @@
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}>"
@@ -0,0 +1,119 @@
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}")
@@ -0,0 +1,65 @@
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
arangoasync/result.py ADDED
@@ -0,0 +1,9 @@
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]