automdev 0.1.0__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.
- autom/__init__.py +61 -0
- autom/_endpoints.py +200 -0
- autom/_operations.py +2058 -0
- autom/_response.py +82 -0
- autom/_serialization.py +105 -0
- autom/_version.py +1 -0
- autom/client.py +529 -0
- autom/errors.py +85 -0
- autom/models.py +81 -0
- autom/py.typed +1 -0
- automdev-0.1.0.dist-info/METADATA +218 -0
- automdev-0.1.0.dist-info/RECORD +14 -0
- automdev-0.1.0.dist-info/WHEEL +4 -0
- automdev-0.1.0.dist-info/licenses/LICENSE +21 -0
autom/client.py
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
"""Blocking and async-I/O Autom clients backed by HTTPX."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Awaitable, Callable, Iterable, Mapping
|
|
9
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
from typing import Any, Literal, Self, TypeVar
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from ._endpoints import ENDPOINTS
|
|
18
|
+
from ._operations import _AsyncOperations, _SyncOperations
|
|
19
|
+
from ._response import create_api_error, create_raw_response, decode_success_response
|
|
20
|
+
from ._serialization import encode_json_body, encode_query, serialize_request
|
|
21
|
+
from ._version import __version__
|
|
22
|
+
from .errors import (
|
|
23
|
+
AutomConfigurationError,
|
|
24
|
+
AutomJobTimeoutError,
|
|
25
|
+
AutomSerializationError,
|
|
26
|
+
AutomTransportError,
|
|
27
|
+
)
|
|
28
|
+
from .models import APIResponse, BatchResult, HTTPMethod, JsonResponse, RawResponse, RequestOptions
|
|
29
|
+
|
|
30
|
+
_I = TypeVar("_I")
|
|
31
|
+
_T = TypeVar("_T")
|
|
32
|
+
|
|
33
|
+
DEFAULT_BASE_URL = "https://api.autom.dev"
|
|
34
|
+
DEFAULT_TIMEOUT = 120.0
|
|
35
|
+
DEFAULT_CONCURRENCY = 10
|
|
36
|
+
"""Tasks run at the same time by ``batch()``."""
|
|
37
|
+
MAX_RETRIES = 3
|
|
38
|
+
"""Retries after a network error or a 408, 429, 500, 502, 503 or 504 response."""
|
|
39
|
+
RETRY_BACKOFF_FACTOR = 1.5
|
|
40
|
+
"""Each retry waits this many times longer than the previous one (a Retry-After header wins)."""
|
|
41
|
+
DEFAULT_RETRY_DELAY = 1.0
|
|
42
|
+
"""Seconds before the first retry."""
|
|
43
|
+
MAX_RETRY_DELAY = 30.0
|
|
44
|
+
RETRYABLE_STATUSES = frozenset({408, 500, 502, 503, 504})
|
|
45
|
+
API_KEY_ENVIRONMENT_VARIABLE = "AUTOM_API_KEY"
|
|
46
|
+
JOB_TERMINAL_STATUSES = frozenset({"CompletedJob", "Error", "NotFound", "BadRequest"})
|
|
47
|
+
MAX_POLL_INTERVAL = 60.0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class _Configuration:
|
|
52
|
+
api_key: str
|
|
53
|
+
base_url: str
|
|
54
|
+
timeout: float | None
|
|
55
|
+
headers: Mapping[str, str]
|
|
56
|
+
max_retries: int
|
|
57
|
+
retry_delay: float
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class _PreparedRequest:
|
|
62
|
+
method: HTTPMethod
|
|
63
|
+
url: str
|
|
64
|
+
headers: dict[str, str]
|
|
65
|
+
content: bytes | None
|
|
66
|
+
timeout: float | None
|
|
67
|
+
expected: Literal["json", "any"]
|
|
68
|
+
max_retries: int
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AutomClient(_SyncOperations):
|
|
72
|
+
"""Reusable blocking Autom client. Use it as a context manager or call ``close()``."""
|
|
73
|
+
|
|
74
|
+
__slots__ = ("_closed", "_configuration", "_http_client", "_owns_http_client")
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
api_key: str | None = None,
|
|
79
|
+
*,
|
|
80
|
+
base_url: str | None = None,
|
|
81
|
+
timeout: float | None = DEFAULT_TIMEOUT,
|
|
82
|
+
headers: Mapping[str, str] | None = None,
|
|
83
|
+
max_retries: int = MAX_RETRIES,
|
|
84
|
+
retry_delay: float = DEFAULT_RETRY_DELAY,
|
|
85
|
+
http_client: httpx.Client | None = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
self._configuration = _build_configuration(
|
|
88
|
+
api_key, base_url, timeout, headers, max_retries, retry_delay
|
|
89
|
+
)
|
|
90
|
+
if http_client is not None and not isinstance(http_client, httpx.Client):
|
|
91
|
+
raise AutomConfigurationError("http_client must be an httpx.Client when provided.")
|
|
92
|
+
self._owns_http_client = http_client is None
|
|
93
|
+
self._http_client = http_client or httpx.Client(follow_redirects=False)
|
|
94
|
+
self._closed = False
|
|
95
|
+
|
|
96
|
+
def request(
|
|
97
|
+
self,
|
|
98
|
+
method: HTTPMethod,
|
|
99
|
+
path: str,
|
|
100
|
+
params: Mapping[str, Any] | None = None,
|
|
101
|
+
*,
|
|
102
|
+
timeout: float | None = None,
|
|
103
|
+
headers: Mapping[str, str] | None = None,
|
|
104
|
+
max_retries: int | None = None,
|
|
105
|
+
) -> APIResponse:
|
|
106
|
+
"""Send any request to the Autom API, including endpoints newer than this SDK."""
|
|
107
|
+
|
|
108
|
+
options = RequestOptions(
|
|
109
|
+
method=method, timeout=timeout, headers=headers, max_retries=max_retries
|
|
110
|
+
)
|
|
111
|
+
return self._send(_prepare(self._configuration, method, path, params, options, "any"))
|
|
112
|
+
|
|
113
|
+
def batch(
|
|
114
|
+
self,
|
|
115
|
+
items: Iterable[_I],
|
|
116
|
+
task: Callable[[_I], _T],
|
|
117
|
+
*,
|
|
118
|
+
concurrency: int = DEFAULT_CONCURRENCY,
|
|
119
|
+
) -> list[BatchResult[_T]]:
|
|
120
|
+
"""Run ``task`` for every item on ``concurrency`` threads (default 10).
|
|
121
|
+
|
|
122
|
+
Returns one :class:`BatchResult` per item, in input order; a failing item never stops the others.
|
|
123
|
+
The client is thread-safe, so ``task`` can call its methods directly.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
_check_concurrency(concurrency)
|
|
127
|
+
values = list(items)
|
|
128
|
+
if not values:
|
|
129
|
+
return []
|
|
130
|
+
with ThreadPoolExecutor(max_workers=min(concurrency, len(values))) as pool:
|
|
131
|
+
futures = [pool.submit(task, value) for value in values]
|
|
132
|
+
return [_settle(index, future) for index, future in enumerate(futures)]
|
|
133
|
+
|
|
134
|
+
def wait_for_job(
|
|
135
|
+
self,
|
|
136
|
+
job_id: str,
|
|
137
|
+
*,
|
|
138
|
+
interval: float = 3.0,
|
|
139
|
+
timeout: float = 43_200.0,
|
|
140
|
+
on_progress: Callable[[Any], None] | None = None,
|
|
141
|
+
) -> JsonResponse:
|
|
142
|
+
"""Poll ``GET /v1/jobs/{id}`` until the job reaches a terminal status.
|
|
143
|
+
|
|
144
|
+
Polls after ``interval`` seconds, then 1.5x longer each time up to 60 s. Raises
|
|
145
|
+
``AutomJobTimeoutError`` after ``timeout`` seconds (default 12 hours).
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
_check_job_id(job_id)
|
|
149
|
+
started = time.monotonic()
|
|
150
|
+
while True:
|
|
151
|
+
last = self.request("GET", "/v1/jobs/{id}", {"id": job_id})
|
|
152
|
+
if not isinstance(last, JsonResponse):
|
|
153
|
+
raise AutomTransportError("The job endpoint did not return JSON.", TypeError())
|
|
154
|
+
if on_progress is not None:
|
|
155
|
+
on_progress(last.data)
|
|
156
|
+
if _is_terminal(last.data):
|
|
157
|
+
return last
|
|
158
|
+
if time.monotonic() - started + interval > timeout:
|
|
159
|
+
raise AutomJobTimeoutError(job_id, last)
|
|
160
|
+
time.sleep(interval)
|
|
161
|
+
interval = _next_interval(interval)
|
|
162
|
+
|
|
163
|
+
def close(self) -> None:
|
|
164
|
+
"""Close transport resources owned by this client."""
|
|
165
|
+
|
|
166
|
+
if self._closed:
|
|
167
|
+
return
|
|
168
|
+
try:
|
|
169
|
+
if self._owns_http_client:
|
|
170
|
+
self._http_client.close()
|
|
171
|
+
finally:
|
|
172
|
+
self._closed = True
|
|
173
|
+
|
|
174
|
+
def __enter__(self) -> Self:
|
|
175
|
+
self._ensure_open()
|
|
176
|
+
return self
|
|
177
|
+
|
|
178
|
+
def __exit__(
|
|
179
|
+
self,
|
|
180
|
+
exc_type: type[BaseException] | None,
|
|
181
|
+
exc_value: BaseException | None,
|
|
182
|
+
traceback: TracebackType | None,
|
|
183
|
+
) -> None:
|
|
184
|
+
self.close()
|
|
185
|
+
|
|
186
|
+
def _call(
|
|
187
|
+
self, name: str, params: dict[str, Any], options: RequestOptions | None
|
|
188
|
+
) -> APIResponse:
|
|
189
|
+
return self._send(_prepare_operation(self._configuration, name, params, options))
|
|
190
|
+
|
|
191
|
+
def _send(self, prepared: _PreparedRequest) -> APIResponse:
|
|
192
|
+
self._ensure_open()
|
|
193
|
+
attempt = 0
|
|
194
|
+
while True:
|
|
195
|
+
try:
|
|
196
|
+
response = self._http_client.request(
|
|
197
|
+
prepared.method,
|
|
198
|
+
prepared.url,
|
|
199
|
+
headers=prepared.headers,
|
|
200
|
+
content=prepared.content,
|
|
201
|
+
timeout=prepared.timeout,
|
|
202
|
+
follow_redirects=False,
|
|
203
|
+
)
|
|
204
|
+
body = response.read()
|
|
205
|
+
except httpx.TimeoutException as cause:
|
|
206
|
+
raise AutomTransportError("Autom request timed out.", cause) from cause
|
|
207
|
+
except Exception as cause:
|
|
208
|
+
if attempt < prepared.max_retries:
|
|
209
|
+
time.sleep(_retry_delay(self._configuration.retry_delay, attempt, None))
|
|
210
|
+
attempt += 1
|
|
211
|
+
continue
|
|
212
|
+
raise AutomTransportError(
|
|
213
|
+
"Autom request failed before an HTTP response body was received.", cause
|
|
214
|
+
) from cause
|
|
215
|
+
raw_response = create_raw_response(response, body)
|
|
216
|
+
if _is_retryable(raw_response) and attempt < prepared.max_retries:
|
|
217
|
+
retry_after = (raw_response.headers.get("retry-after") or (None,))[0]
|
|
218
|
+
time.sleep(_retry_delay(self._configuration.retry_delay, attempt, retry_after))
|
|
219
|
+
attempt += 1
|
|
220
|
+
continue
|
|
221
|
+
return _decode(raw_response, prepared)
|
|
222
|
+
|
|
223
|
+
def _ensure_open(self) -> None:
|
|
224
|
+
if self._closed:
|
|
225
|
+
raise AutomConfigurationError("This AutomClient is closed.")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class AsyncAutomClient(_AsyncOperations):
|
|
229
|
+
"""Reusable async-I/O Autom client. Use it as an async context manager or await ``aclose()``."""
|
|
230
|
+
|
|
231
|
+
__slots__ = ("_closed", "_configuration", "_http_client", "_owns_http_client")
|
|
232
|
+
|
|
233
|
+
def __init__(
|
|
234
|
+
self,
|
|
235
|
+
api_key: str | None = None,
|
|
236
|
+
*,
|
|
237
|
+
base_url: str | None = None,
|
|
238
|
+
timeout: float | None = DEFAULT_TIMEOUT,
|
|
239
|
+
headers: Mapping[str, str] | None = None,
|
|
240
|
+
max_retries: int = MAX_RETRIES,
|
|
241
|
+
retry_delay: float = DEFAULT_RETRY_DELAY,
|
|
242
|
+
http_client: httpx.AsyncClient | None = None,
|
|
243
|
+
) -> None:
|
|
244
|
+
self._configuration = _build_configuration(
|
|
245
|
+
api_key, base_url, timeout, headers, max_retries, retry_delay
|
|
246
|
+
)
|
|
247
|
+
if http_client is not None and not isinstance(http_client, httpx.AsyncClient):
|
|
248
|
+
raise AutomConfigurationError("http_client must be an httpx.AsyncClient when provided.")
|
|
249
|
+
self._owns_http_client = http_client is None
|
|
250
|
+
self._http_client = http_client or httpx.AsyncClient(follow_redirects=False)
|
|
251
|
+
self._closed = False
|
|
252
|
+
|
|
253
|
+
async def request(
|
|
254
|
+
self,
|
|
255
|
+
method: HTTPMethod,
|
|
256
|
+
path: str,
|
|
257
|
+
params: Mapping[str, Any] | None = None,
|
|
258
|
+
*,
|
|
259
|
+
timeout: float | None = None,
|
|
260
|
+
headers: Mapping[str, str] | None = None,
|
|
261
|
+
max_retries: int | None = None,
|
|
262
|
+
) -> APIResponse:
|
|
263
|
+
"""Send any request to the Autom API, including endpoints newer than this SDK."""
|
|
264
|
+
|
|
265
|
+
options = RequestOptions(
|
|
266
|
+
method=method, timeout=timeout, headers=headers, max_retries=max_retries
|
|
267
|
+
)
|
|
268
|
+
return await self._send(_prepare(self._configuration, method, path, params, options, "any"))
|
|
269
|
+
|
|
270
|
+
async def batch(
|
|
271
|
+
self,
|
|
272
|
+
items: Iterable[_I],
|
|
273
|
+
task: Callable[[_I], Awaitable[_T]],
|
|
274
|
+
*,
|
|
275
|
+
concurrency: int = DEFAULT_CONCURRENCY,
|
|
276
|
+
) -> list[BatchResult[_T]]:
|
|
277
|
+
"""Await ``task`` for every item with at most ``concurrency`` in flight (default 10).
|
|
278
|
+
|
|
279
|
+
Returns one :class:`BatchResult` per item, in input order; a failing item never stops the others.
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
_check_concurrency(concurrency)
|
|
283
|
+
semaphore = asyncio.Semaphore(concurrency)
|
|
284
|
+
|
|
285
|
+
async def run(index: int, value: _I) -> BatchResult[_T]:
|
|
286
|
+
async with semaphore:
|
|
287
|
+
try:
|
|
288
|
+
return BatchResult(index=index, value=await task(value))
|
|
289
|
+
except Exception as error:
|
|
290
|
+
return BatchResult(index=index, error=error)
|
|
291
|
+
|
|
292
|
+
return list(await asyncio.gather(*(run(i, v) for i, v in enumerate(items))))
|
|
293
|
+
|
|
294
|
+
async def wait_for_job(
|
|
295
|
+
self,
|
|
296
|
+
job_id: str,
|
|
297
|
+
*,
|
|
298
|
+
interval: float = 3.0,
|
|
299
|
+
timeout: float = 43_200.0,
|
|
300
|
+
on_progress: Callable[[Any], None] | None = None,
|
|
301
|
+
) -> JsonResponse:
|
|
302
|
+
"""Poll ``GET /v1/jobs/{id}`` until the job reaches a terminal status."""
|
|
303
|
+
|
|
304
|
+
_check_job_id(job_id)
|
|
305
|
+
started = time.monotonic()
|
|
306
|
+
while True:
|
|
307
|
+
last = await self.request("GET", "/v1/jobs/{id}", {"id": job_id})
|
|
308
|
+
if not isinstance(last, JsonResponse):
|
|
309
|
+
raise AutomTransportError("The job endpoint did not return JSON.", TypeError())
|
|
310
|
+
if on_progress is not None:
|
|
311
|
+
on_progress(last.data)
|
|
312
|
+
if _is_terminal(last.data):
|
|
313
|
+
return last
|
|
314
|
+
if time.monotonic() - started + interval > timeout:
|
|
315
|
+
raise AutomJobTimeoutError(job_id, last)
|
|
316
|
+
await asyncio.sleep(interval)
|
|
317
|
+
interval = _next_interval(interval)
|
|
318
|
+
|
|
319
|
+
async def aclose(self) -> None:
|
|
320
|
+
"""Close transport resources owned by this client."""
|
|
321
|
+
|
|
322
|
+
if self._closed:
|
|
323
|
+
return
|
|
324
|
+
try:
|
|
325
|
+
if self._owns_http_client:
|
|
326
|
+
await self._http_client.aclose()
|
|
327
|
+
finally:
|
|
328
|
+
self._closed = True
|
|
329
|
+
|
|
330
|
+
async def __aenter__(self) -> Self:
|
|
331
|
+
self._ensure_open()
|
|
332
|
+
return self
|
|
333
|
+
|
|
334
|
+
async def __aexit__(
|
|
335
|
+
self,
|
|
336
|
+
exc_type: type[BaseException] | None,
|
|
337
|
+
exc_value: BaseException | None,
|
|
338
|
+
traceback: TracebackType | None,
|
|
339
|
+
) -> None:
|
|
340
|
+
await self.aclose()
|
|
341
|
+
|
|
342
|
+
async def _call(
|
|
343
|
+
self, name: str, params: dict[str, Any], options: RequestOptions | None
|
|
344
|
+
) -> APIResponse:
|
|
345
|
+
return await self._send(_prepare_operation(self._configuration, name, params, options))
|
|
346
|
+
|
|
347
|
+
async def _send(self, prepared: _PreparedRequest) -> APIResponse:
|
|
348
|
+
self._ensure_open()
|
|
349
|
+
attempt = 0
|
|
350
|
+
while True:
|
|
351
|
+
try:
|
|
352
|
+
response = await self._http_client.request(
|
|
353
|
+
prepared.method,
|
|
354
|
+
prepared.url,
|
|
355
|
+
headers=prepared.headers,
|
|
356
|
+
content=prepared.content,
|
|
357
|
+
timeout=prepared.timeout,
|
|
358
|
+
follow_redirects=False,
|
|
359
|
+
)
|
|
360
|
+
body = await response.aread()
|
|
361
|
+
except httpx.TimeoutException as cause:
|
|
362
|
+
raise AutomTransportError("Autom request timed out.", cause) from cause
|
|
363
|
+
except Exception as cause:
|
|
364
|
+
if attempt < prepared.max_retries:
|
|
365
|
+
await asyncio.sleep(
|
|
366
|
+
_retry_delay(self._configuration.retry_delay, attempt, None)
|
|
367
|
+
)
|
|
368
|
+
attempt += 1
|
|
369
|
+
continue
|
|
370
|
+
raise AutomTransportError(
|
|
371
|
+
"Autom request failed before an HTTP response body was received.", cause
|
|
372
|
+
) from cause
|
|
373
|
+
raw_response = create_raw_response(response, body)
|
|
374
|
+
if _is_retryable(raw_response) and attempt < prepared.max_retries:
|
|
375
|
+
retry_after = (raw_response.headers.get("retry-after") or (None,))[0]
|
|
376
|
+
await asyncio.sleep(
|
|
377
|
+
_retry_delay(self._configuration.retry_delay, attempt, retry_after)
|
|
378
|
+
)
|
|
379
|
+
attempt += 1
|
|
380
|
+
continue
|
|
381
|
+
return _decode(raw_response, prepared)
|
|
382
|
+
|
|
383
|
+
def _ensure_open(self) -> None:
|
|
384
|
+
if self._closed:
|
|
385
|
+
raise AutomConfigurationError("This AsyncAutomClient is closed.")
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _build_configuration(
|
|
389
|
+
api_key: str | None,
|
|
390
|
+
base_url: str | None,
|
|
391
|
+
timeout: float | None,
|
|
392
|
+
headers: Mapping[str, str] | None,
|
|
393
|
+
max_retries: int = MAX_RETRIES,
|
|
394
|
+
retry_delay: float = DEFAULT_RETRY_DELAY,
|
|
395
|
+
) -> _Configuration:
|
|
396
|
+
key = api_key if api_key is not None else os.environ.get(API_KEY_ENVIRONMENT_VARIABLE)
|
|
397
|
+
if not isinstance(key, str) or not key.strip():
|
|
398
|
+
raise AutomConfigurationError(
|
|
399
|
+
f"A non-blank Autom API key is required. Pass api_key or set {API_KEY_ENVIRONMENT_VARIABLE}."
|
|
400
|
+
)
|
|
401
|
+
if any(ch in key for ch in "\r\n"):
|
|
402
|
+
raise AutomConfigurationError("The Autom API key cannot be used in an HTTP header.")
|
|
403
|
+
url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
404
|
+
parts = urlsplit(url)
|
|
405
|
+
if parts.scheme not in ("http", "https") or not parts.netloc:
|
|
406
|
+
raise AutomConfigurationError("base_url must be an absolute http or https URL.")
|
|
407
|
+
if timeout is not None and (not isinstance(timeout, (int, float)) or timeout <= 0):
|
|
408
|
+
raise AutomConfigurationError("timeout must be a positive number of seconds or None.")
|
|
409
|
+
if isinstance(max_retries, bool) or not isinstance(max_retries, int) or max_retries < 0:
|
|
410
|
+
raise AutomConfigurationError("max_retries must be a non-negative integer.")
|
|
411
|
+
if not isinstance(retry_delay, (int, float)) or retry_delay < 0:
|
|
412
|
+
raise AutomConfigurationError("retry_delay must be a non-negative number of seconds.")
|
|
413
|
+
return _Configuration(
|
|
414
|
+
api_key=key,
|
|
415
|
+
base_url=url,
|
|
416
|
+
timeout=timeout,
|
|
417
|
+
headers=dict(headers or {}),
|
|
418
|
+
max_retries=max_retries,
|
|
419
|
+
retry_delay=float(retry_delay),
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _prepare_operation(
|
|
424
|
+
configuration: _Configuration,
|
|
425
|
+
name: str,
|
|
426
|
+
params: Mapping[str, Any],
|
|
427
|
+
options: RequestOptions | None,
|
|
428
|
+
) -> _PreparedRequest:
|
|
429
|
+
endpoint = ENDPOINTS[name]
|
|
430
|
+
method = (options.method if options else None) or endpoint.methods[0]
|
|
431
|
+
if method not in endpoint.methods:
|
|
432
|
+
raise AutomConfigurationError(
|
|
433
|
+
f"{endpoint.path} supports {', '.join(endpoint.methods)}, not {method}."
|
|
434
|
+
)
|
|
435
|
+
for required in endpoint.required:
|
|
436
|
+
if params.get(required) is None:
|
|
437
|
+
raise AutomSerializationError(f'The "{required}" parameter is required.', required)
|
|
438
|
+
return _prepare(configuration, method, endpoint.path, params, options, endpoint.response)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _prepare(
|
|
442
|
+
configuration: _Configuration,
|
|
443
|
+
method: HTTPMethod,
|
|
444
|
+
path: str,
|
|
445
|
+
params: Mapping[str, Any] | None,
|
|
446
|
+
options: RequestOptions | None,
|
|
447
|
+
expected: Literal["json", "any"],
|
|
448
|
+
) -> _PreparedRequest:
|
|
449
|
+
if method not in ("GET", "POST"):
|
|
450
|
+
raise AutomConfigurationError(f"Unsupported HTTP method: {method!r}.")
|
|
451
|
+
if not isinstance(path, str) or not path.startswith("/"):
|
|
452
|
+
raise AutomConfigurationError('The request path must start with "/".')
|
|
453
|
+
serialized = serialize_request(method, path, params)
|
|
454
|
+
url = configuration.base_url + serialized.path
|
|
455
|
+
if serialized.query:
|
|
456
|
+
url += "?" + encode_query(serialized.query)
|
|
457
|
+
headers = {
|
|
458
|
+
"Accept": "application/json, */*;q=0.8",
|
|
459
|
+
"User-Agent": f"autom-python/{__version__}",
|
|
460
|
+
**configuration.headers,
|
|
461
|
+
**dict((options.headers if options else None) or {}),
|
|
462
|
+
"x-api-key": configuration.api_key,
|
|
463
|
+
}
|
|
464
|
+
content = None
|
|
465
|
+
if serialized.body is not None:
|
|
466
|
+
headers["Content-Type"] = "application/json"
|
|
467
|
+
content = encode_json_body(serialized.body)
|
|
468
|
+
timeout = options.timeout if options and options.timeout is not None else configuration.timeout
|
|
469
|
+
max_retries = (
|
|
470
|
+
options.max_retries
|
|
471
|
+
if options and options.max_retries is not None
|
|
472
|
+
else configuration.max_retries
|
|
473
|
+
)
|
|
474
|
+
return _PreparedRequest(method, url, headers, content, timeout, expected, max_retries)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _decode(raw_response: RawResponse, prepared: _PreparedRequest) -> APIResponse:
|
|
478
|
+
if not 200 <= raw_response.status <= 299:
|
|
479
|
+
raise create_api_error(raw_response)
|
|
480
|
+
return decode_success_response(raw_response, prepared.expected)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _check_concurrency(concurrency: int) -> None:
|
|
484
|
+
if isinstance(concurrency, bool) or not isinstance(concurrency, int) or concurrency < 1:
|
|
485
|
+
raise AutomConfigurationError("concurrency must be a positive integer.")
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _settle(index: int, future: Future[_T]) -> BatchResult[_T]:
|
|
489
|
+
error = future.exception()
|
|
490
|
+
if error is not None:
|
|
491
|
+
return BatchResult(index=index, error=error)
|
|
492
|
+
return BatchResult(index=index, value=future.result())
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _is_retryable(raw_response: RawResponse) -> bool:
|
|
496
|
+
"""Server errors and plan rate limits (401/429 "Rate limit exceeded") are retried; per-key quotas are not."""
|
|
497
|
+
|
|
498
|
+
if raw_response.status in RETRYABLE_STATUSES:
|
|
499
|
+
return True
|
|
500
|
+
if raw_response.status not in (401, 429):
|
|
501
|
+
return False
|
|
502
|
+
message = (create_api_error(raw_response).error_message or "").lower()
|
|
503
|
+
if "quota" in message:
|
|
504
|
+
return False
|
|
505
|
+
return "rate limit" in message or (raw_response.status == 429 and not message)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _retry_delay(base: float, attempt: int, retry_after: str | None) -> float:
|
|
509
|
+
if retry_after is not None:
|
|
510
|
+
try:
|
|
511
|
+
seconds = float(retry_after)
|
|
512
|
+
except ValueError:
|
|
513
|
+
seconds = -1.0
|
|
514
|
+
if seconds >= 0:
|
|
515
|
+
return min(seconds, 60.0)
|
|
516
|
+
return float(min(base * RETRY_BACKOFF_FACTOR**attempt, MAX_RETRY_DELAY))
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _check_job_id(job_id: str) -> None:
|
|
520
|
+
if not isinstance(job_id, str) or not job_id.strip():
|
|
521
|
+
raise AutomSerializationError("A non-blank job id is required.", "id")
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _is_terminal(job: Any) -> bool:
|
|
525
|
+
return isinstance(job, dict) and job.get("status") in JOB_TERMINAL_STATUSES
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _next_interval(interval: float) -> float:
|
|
529
|
+
return max(interval, min(interval * 1.5, MAX_POLL_INTERVAL))
|
autom/errors.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Autom SDK error categories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .models import JsonResponse, RawResponse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AutomError(Exception):
|
|
11
|
+
"""Base class for all SDK-defined errors."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AutomConfigurationError(AutomError):
|
|
15
|
+
"""The client configuration is missing or unusable."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AutomSerializationError(AutomError):
|
|
19
|
+
"""A request value cannot be represented safely on the wire."""
|
|
20
|
+
|
|
21
|
+
param: str | None
|
|
22
|
+
|
|
23
|
+
def __init__(self, message: str, param: str | None = None) -> None:
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.param = param
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AutomTransportError(AutomError):
|
|
29
|
+
"""The request failed before a complete HTTP response was received."""
|
|
30
|
+
|
|
31
|
+
cause: Exception
|
|
32
|
+
|
|
33
|
+
def __init__(self, message: str, cause: Exception) -> None:
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.cause = cause
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AutomAPIError(AutomError):
|
|
39
|
+
"""Autom returned a non-success HTTP status."""
|
|
40
|
+
|
|
41
|
+
status: int
|
|
42
|
+
error_message: str | None
|
|
43
|
+
response_body: Any | None
|
|
44
|
+
raw_response: RawResponse
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
raw_response: RawResponse,
|
|
50
|
+
response_body: Any | None = None,
|
|
51
|
+
error_message: str | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
super().__init__(
|
|
54
|
+
error_message or f"Autom API request failed with status {raw_response.status}."
|
|
55
|
+
)
|
|
56
|
+
self.status = raw_response.status
|
|
57
|
+
self.error_message = error_message
|
|
58
|
+
self.response_body = response_body
|
|
59
|
+
self.raw_response = raw_response
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AutomResponseDecodingError(AutomError):
|
|
63
|
+
"""A successful response could not be decoded as the documented representation."""
|
|
64
|
+
|
|
65
|
+
raw_response: RawResponse
|
|
66
|
+
cause: Exception | None
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self, message: str, raw_response: RawResponse, cause: Exception | None = None
|
|
70
|
+
) -> None:
|
|
71
|
+
super().__init__(message)
|
|
72
|
+
self.raw_response = raw_response
|
|
73
|
+
self.cause = cause
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class AutomJobTimeoutError(AutomError):
|
|
77
|
+
"""An async job did not reach a terminal status before the deadline."""
|
|
78
|
+
|
|
79
|
+
job_id: str
|
|
80
|
+
last_response: JsonResponse | None
|
|
81
|
+
|
|
82
|
+
def __init__(self, job_id: str, last_response: JsonResponse | None = None) -> None:
|
|
83
|
+
super().__init__(f"Job {job_id} did not finish before the deadline.")
|
|
84
|
+
self.job_id = job_id
|
|
85
|
+
self.last_response = last_response
|
autom/models.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Public response and option types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Generic, Literal, TypeAlias, TypeVar
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
HTTPMethod: TypeAlias = Literal["GET", "POST"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class RawResponse:
|
|
16
|
+
"""Buffered HTTP response: status, lower-cased multi-value headers, content type and body bytes."""
|
|
17
|
+
|
|
18
|
+
status: int
|
|
19
|
+
status_text: str
|
|
20
|
+
headers: Mapping[str, tuple[str, ...]]
|
|
21
|
+
content_type: str | None
|
|
22
|
+
body: bytes = field(repr=False)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class JsonResponse:
|
|
27
|
+
"""A successful JSON response; ``data`` is the parsed body."""
|
|
28
|
+
|
|
29
|
+
data: Any
|
|
30
|
+
raw_response: RawResponse = field(repr=False)
|
|
31
|
+
kind: Literal["json"] = "json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class BinaryResponse:
|
|
36
|
+
"""A successful non-JSON response (image, PDF, HTML…)."""
|
|
37
|
+
|
|
38
|
+
bytes: bytes = field(repr=False)
|
|
39
|
+
content_type: str | None = None
|
|
40
|
+
raw_response: RawResponse | None = field(default=None, repr=False)
|
|
41
|
+
kind: Literal["binary"] = "binary"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
APIResponse: TypeAlias = JsonResponse | BinaryResponse
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class RequestOptions:
|
|
49
|
+
"""Per-request options for generated endpoint methods."""
|
|
50
|
+
|
|
51
|
+
method: HTTPMethod | None = None
|
|
52
|
+
"""GET (default when supported) sends query parameters; POST sends a JSON body."""
|
|
53
|
+
timeout: float | None = None
|
|
54
|
+
"""Seconds; overrides the client timeout for this request."""
|
|
55
|
+
headers: Mapping[str, str] | None = None
|
|
56
|
+
"""Extra headers for this request."""
|
|
57
|
+
max_retries: int | None = None
|
|
58
|
+
"""Overrides the client retry count for this request (0 disables retries)."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class BatchResult(Generic[T]):
|
|
63
|
+
"""Outcome of one ``batch()`` item: ``value`` when ``ok``, else ``error``."""
|
|
64
|
+
|
|
65
|
+
index: int
|
|
66
|
+
value: T | None = None
|
|
67
|
+
error: BaseException | None = None
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def ok(self) -> bool:
|
|
71
|
+
return self.error is None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class EndpointDefinition:
|
|
76
|
+
"""Path, supported methods and required parameters of one documented operation."""
|
|
77
|
+
|
|
78
|
+
path: str
|
|
79
|
+
methods: tuple[HTTPMethod, ...]
|
|
80
|
+
response: Literal["json", "any"]
|
|
81
|
+
required: tuple[str, ...]
|
autom/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|