lowcoai-api 1.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.
- lowcoai/api/__init__.py +47 -0
- lowcoai/api/_core.py +343 -0
- lowcoai/api/_errors.py +55 -0
- lowcoai/api/_files.py +91 -0
- lowcoai/api/_generated.py +7140 -0
- lowcoai/api/_webhooks.py +54 -0
- lowcoai/api/py.typed +0 -0
- lowcoai_api-1.1.0.dist-info/METADATA +78 -0
- lowcoai_api-1.1.0.dist-info/RECORD +11 -0
- lowcoai_api-1.1.0.dist-info/WHEEL +4 -0
- lowcoai_api-1.1.0.dist-info/licenses/LICENSE +21 -0
lowcoai/api/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Official Python client for the Lowco API: PDF, render, OCR, Document AI,
|
|
2
|
+
QR and barcodes, with jobs, file inputs, webhooks and typed errors.
|
|
3
|
+
|
|
4
|
+
from lowcoai.api import Files, LowcoApi
|
|
5
|
+
|
|
6
|
+
lowco = LowcoApi() # LOWCO_API_KEY
|
|
7
|
+
answer = lowco.docai.invoice(file=Files.path("invoice.pdf"), mode="async")
|
|
8
|
+
invoice = lowco.wait_for_result(answer)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import uuid
|
|
14
|
+
from typing import Optional, TypeVar
|
|
15
|
+
|
|
16
|
+
from ._core import DEFAULT_BASE_URL, SDK_VERSION, Answer, Core, FileInput
|
|
17
|
+
from ._errors import LowcoApiError, LowcoJobError, LowcoTimeoutError
|
|
18
|
+
from ._files import FileRef, Files
|
|
19
|
+
from ._generated import API_VERSION, ERROR_REASONS, ErrorReason, GeneratedClient
|
|
20
|
+
from ._webhooks import SIGNATURE_HEADER, LowcoSignatureError, construct_event, verify_signature
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"API_VERSION", "DEFAULT_BASE_URL", "ERROR_REASONS", "SDK_VERSION", "SIGNATURE_HEADER",
|
|
24
|
+
"Answer", "ErrorReason", "FileInput", "FileRef", "Files", "LowcoApi", "LowcoApiError", "LowcoJobError",
|
|
25
|
+
"LowcoSignatureError", "LowcoTimeoutError", "construct_event", "new_idempotency_key", "verify_signature",
|
|
26
|
+
]
|
|
27
|
+
__version__ = SDK_VERSION
|
|
28
|
+
|
|
29
|
+
T = TypeVar("T")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def new_idempotency_key() -> str:
|
|
33
|
+
"""A new Idempotency-Key: store it with your record before calling, and reuse it on retries."""
|
|
34
|
+
return str(uuid.uuid4())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class LowcoApi(GeneratedClient):
|
|
38
|
+
"""The Lowco API client. ``api_key`` defaults to LOWCO_API_KEY and
|
|
39
|
+
``base_url`` to LOWCO_API_BASE_URL, else https://api.lowco.ai."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, api_key: Optional[str] = None, *, base_url: Optional[str] = None, timeout: float = 60.0,
|
|
42
|
+
max_retries: int = 2) -> None:
|
|
43
|
+
super().__init__(Core(api_key, base_url=base_url, timeout=timeout, max_retries=max_retries))
|
|
44
|
+
|
|
45
|
+
def wait_for_result(self, answer: Answer[T], *, timeout: float = 300.0, max_interval: float = 10.0) -> T:
|
|
46
|
+
"""The result of an operation's answer: inline data, or the job's result once it finishes."""
|
|
47
|
+
return self._core.wait_for_result(answer, timeout=timeout, max_interval=max_interval)
|
lowcoai/api/_core.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""The transport every generated method calls: auth, the envelope, typed
|
|
2
|
+
errors, file inputs (multipart or by reference), job waiting, pagination and
|
|
3
|
+
retries. Standard library only."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import mimetypes
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import IO, Any, Dict, Generic, Iterator, List, Mapping, Optional, Tuple, TypeVar, Union
|
|
14
|
+
from urllib import error as urlerror
|
|
15
|
+
from urllib import parse, request
|
|
16
|
+
|
|
17
|
+
from ._errors import LowcoApiError, LowcoJobError, LowcoTimeoutError
|
|
18
|
+
from ._files import FileRef, Files
|
|
19
|
+
|
|
20
|
+
DEFAULT_BASE_URL = "https://api.lowco.ai"
|
|
21
|
+
SDK_VERSION = "1.1.0"
|
|
22
|
+
_TERMINAL = {"succeeded", "failed", "cancelled"}
|
|
23
|
+
|
|
24
|
+
T = TypeVar("T")
|
|
25
|
+
|
|
26
|
+
FileInput = Union[FileRef, bytes, bytearray, memoryview, IO[bytes]]
|
|
27
|
+
"""A file input: build one with ``Files``, or pass bytes or a binary file object."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Answer(Generic[T]):
|
|
32
|
+
"""An operation's answer: the result inline (200), a job (202), or the output file's bytes."""
|
|
33
|
+
|
|
34
|
+
status: int
|
|
35
|
+
data: Optional[T] = None
|
|
36
|
+
"""The result, when the operation finished in the request."""
|
|
37
|
+
job: Optional[Dict[str, Any]] = None
|
|
38
|
+
"""The job, when the operation was accepted as one (status 202)."""
|
|
39
|
+
bytes: Optional[bytes] = None
|
|
40
|
+
"""The output file, when ``accept`` asked for its type."""
|
|
41
|
+
content_type: Optional[str] = None
|
|
42
|
+
request_id: Optional[str] = None
|
|
43
|
+
replayed: bool = False
|
|
44
|
+
"""True when an Idempotency-Key replayed an earlier submission."""
|
|
45
|
+
credits_used: Optional[str] = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class _Raw:
|
|
50
|
+
status: int
|
|
51
|
+
headers: Mapping[str, str]
|
|
52
|
+
body: Any = None
|
|
53
|
+
content: Optional[bytes] = None
|
|
54
|
+
content_type: str = ""
|
|
55
|
+
location: Optional[str] = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _NoRedirect(request.HTTPRedirectHandler):
|
|
59
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _normalise(value: Any) -> FileRef:
|
|
64
|
+
if isinstance(value, FileRef):
|
|
65
|
+
return value
|
|
66
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
67
|
+
return Files.bytes(bytes(value))
|
|
68
|
+
if hasattr(value, "read"):
|
|
69
|
+
return Files.stream(value)
|
|
70
|
+
raise LowcoApiError("A file input must be built with Files (path, bytes, stream, url, id, base64)", status=0, reason="invalid_param")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _multipart(fields: List[Tuple[str, str]], files: List[Tuple[str, str, str, bytes]]) -> Tuple[bytes, str]:
|
|
74
|
+
boundary = "lowco" + uuid.uuid4().hex
|
|
75
|
+
out = bytearray()
|
|
76
|
+
for name, value in fields:
|
|
77
|
+
out += f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n".encode()
|
|
78
|
+
out += value.encode() + b"\r\n"
|
|
79
|
+
for name, filename, mime, data in files:
|
|
80
|
+
safe = filename.replace('"', "_")
|
|
81
|
+
out += f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"; filename=\"{safe}\"\r\nContent-Type: {mime}\r\n\r\n".encode()
|
|
82
|
+
out += data + b"\r\n"
|
|
83
|
+
out += f"--{boundary}--\r\n".encode()
|
|
84
|
+
return bytes(out), f"multipart/form-data; boundary={boundary}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Core:
|
|
88
|
+
"""Sends requests and decodes answers."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, api_key: Optional[str] = None, *, base_url: Optional[str] = None, timeout: float = 60.0, max_retries: int = 2) -> None:
|
|
91
|
+
key = api_key or os.environ.get("LOWCO_API_KEY")
|
|
92
|
+
if not key:
|
|
93
|
+
raise LowcoApiError("api_key is required (lc_live_… or lc_test_…): pass it or set LOWCO_API_KEY", status=0, reason="missing_key")
|
|
94
|
+
self._key = key
|
|
95
|
+
self.base_url = (base_url or os.environ.get("LOWCO_API_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
96
|
+
self._timeout = timeout
|
|
97
|
+
self._max_retries = max_retries
|
|
98
|
+
self._opener = request.build_opener()
|
|
99
|
+
self._no_redirect = request.build_opener(_NoRedirect)
|
|
100
|
+
|
|
101
|
+
def _url(self, path: str, query: Optional[Mapping[str, Any]] = None) -> str:
|
|
102
|
+
url = path if path.startswith("http") else self.base_url + path
|
|
103
|
+
q = {k: ("true" if v is True else "false" if v is False else v) for k, v in (query or {}).items() if v is not None}
|
|
104
|
+
if q:
|
|
105
|
+
url += ("&" if "?" in url else "?") + parse.urlencode(q)
|
|
106
|
+
return url
|
|
107
|
+
|
|
108
|
+
def _send(
|
|
109
|
+
self,
|
|
110
|
+
method: str,
|
|
111
|
+
url: str,
|
|
112
|
+
*,
|
|
113
|
+
body: Optional[bytes] = None,
|
|
114
|
+
headers: Optional[Dict[str, str]] = None,
|
|
115
|
+
auth: bool = True,
|
|
116
|
+
follow: bool = True,
|
|
117
|
+
request_id: Optional[str] = None,
|
|
118
|
+
idempotency_key: Optional[str] = None,
|
|
119
|
+
timeout: Optional[float] = None,
|
|
120
|
+
retry_safe: Optional[bool] = None,
|
|
121
|
+
) -> _Raw:
|
|
122
|
+
h = {"X-Client-User-Agent": f"lowcoai-api-python/{SDK_VERSION}", "Accept": "application/json"}
|
|
123
|
+
h.update(headers or {})
|
|
124
|
+
if auth:
|
|
125
|
+
h["Authorization"] = f"Bearer {self._key}"
|
|
126
|
+
if request_id:
|
|
127
|
+
h["X-Request-Id"] = request_id
|
|
128
|
+
if idempotency_key:
|
|
129
|
+
h["Idempotency-Key"] = idempotency_key
|
|
130
|
+
safe = (method == "GET" if retry_safe is None else retry_safe) or bool(idempotency_key)
|
|
131
|
+
attempt = 0
|
|
132
|
+
while True:
|
|
133
|
+
req = request.Request(url, data=body, method=method, headers=h)
|
|
134
|
+
opener = self._opener if follow else self._no_redirect
|
|
135
|
+
try:
|
|
136
|
+
with opener.open(req, timeout=timeout or self._timeout) as resp:
|
|
137
|
+
return self._read(resp.status, resp.headers, resp.read())
|
|
138
|
+
except urlerror.HTTPError as e:
|
|
139
|
+
try:
|
|
140
|
+
raw = self._read(e.code, e.headers, e.read() if e.fp else b"")
|
|
141
|
+
finally:
|
|
142
|
+
e.close()
|
|
143
|
+
if 300 <= e.code < 400:
|
|
144
|
+
return raw
|
|
145
|
+
err = self._error(raw)
|
|
146
|
+
if safe and attempt < self._max_retries and e.code in (429, 503):
|
|
147
|
+
time.sleep(min(err.retry_after if err.retry_after is not None else 1, 30))
|
|
148
|
+
attempt += 1
|
|
149
|
+
continue
|
|
150
|
+
raise err from None
|
|
151
|
+
except (urlerror.URLError, TimeoutError, OSError) as e:
|
|
152
|
+
if safe and attempt < self._max_retries:
|
|
153
|
+
time.sleep(0.5 * 2**attempt)
|
|
154
|
+
attempt += 1
|
|
155
|
+
continue
|
|
156
|
+
raise LowcoApiError(f"Network error: {e}", status=0, reason="service_unavailable", retryable=True) from e
|
|
157
|
+
|
|
158
|
+
def _read(self, status: int, headers: Any, content: bytes) -> _Raw:
|
|
159
|
+
ctype = headers.get("Content-Type", "") if headers else ""
|
|
160
|
+
hdrs = {k.lower(): v for k, v in headers.items()} if headers else {}
|
|
161
|
+
raw = _Raw(status=status, headers=hdrs, content_type=ctype, location=hdrs.get("location"))
|
|
162
|
+
if "json" in ctype:
|
|
163
|
+
try:
|
|
164
|
+
raw.body = json.loads(content.decode("utf-8")) if content else None
|
|
165
|
+
except ValueError:
|
|
166
|
+
raw.body = content.decode("utf-8", "replace")
|
|
167
|
+
else:
|
|
168
|
+
raw.content = content
|
|
169
|
+
return raw
|
|
170
|
+
|
|
171
|
+
def _error(self, raw: _Raw) -> LowcoApiError:
|
|
172
|
+
ra = raw.headers.get("retry-after")
|
|
173
|
+
retry_after = int(ra) if ra and ra.isdigit() else None
|
|
174
|
+
e = raw.body.get("error") if isinstance(raw.body, dict) else None
|
|
175
|
+
rid = (e or {}).get("request_id") or raw.headers.get("x-request-id")
|
|
176
|
+
if isinstance(e, dict):
|
|
177
|
+
return LowcoApiError(
|
|
178
|
+
str(e.get("message") or f"Request failed with status {raw.status}"),
|
|
179
|
+
status=raw.status, reason=str(e.get("reason") or "internal_error"), param=e.get("param"),
|
|
180
|
+
details=e.get("details"), request_id=rid, doc_url=e.get("doc_url"), retryable=e.get("retryable"),
|
|
181
|
+
retry_after=retry_after, payload=raw.body,
|
|
182
|
+
)
|
|
183
|
+
text = raw.body if isinstance(raw.body, str) else (raw.content or b"").decode("utf-8", "replace")
|
|
184
|
+
return LowcoApiError(text or f"Request failed with status {raw.status}", status=raw.status,
|
|
185
|
+
reason="internal_error" if raw.status >= 500 else "invalid_param", request_id=rid,
|
|
186
|
+
retry_after=retry_after, payload=raw.body if raw.body is not None else text)
|
|
187
|
+
|
|
188
|
+
@staticmethod
|
|
189
|
+
def _data(raw: _Raw) -> Any:
|
|
190
|
+
if not isinstance(raw.body, dict) or raw.body.get("status") != 1:
|
|
191
|
+
raise LowcoApiError("The API answered without a success envelope", status=raw.status, reason="internal_error", payload=raw.body)
|
|
192
|
+
return raw.body.get("data")
|
|
193
|
+
|
|
194
|
+
def call(self, method: str, path: str, *, query: Optional[Mapping[str, Any]] = None, json: Any = None,
|
|
195
|
+
idempotency_key: Optional[str] = None, request_id: Optional[str] = None) -> Any:
|
|
196
|
+
body = None
|
|
197
|
+
headers: Dict[str, str] = {}
|
|
198
|
+
if json is not None:
|
|
199
|
+
body = _json_bytes(json)
|
|
200
|
+
headers["Content-Type"] = "application/json"
|
|
201
|
+
raw = self._send(method, self._url(path, query), body=body, headers=headers,
|
|
202
|
+
idempotency_key=idempotency_key, request_id=request_id)
|
|
203
|
+
return self._data(raw)
|
|
204
|
+
|
|
205
|
+
def upload(self, path: str, file: FileInput, *, request_id: Optional[str] = None) -> Any:
|
|
206
|
+
ref = _normalise(file)
|
|
207
|
+
if not ref.is_local:
|
|
208
|
+
raise LowcoApiError("upload takes a local file (path, bytes or stream)", status=0, reason="invalid_param", param="file")
|
|
209
|
+
name, mime, data = ref.read()
|
|
210
|
+
fields = [("password", ref.password)] if ref.password else []
|
|
211
|
+
body, ctype = _multipart(fields, [("file", name, mime, data)])
|
|
212
|
+
return self._data(self._send("POST", self._url(path), body=body, headers={"Content-Type": ctype}, request_id=request_id))
|
|
213
|
+
|
|
214
|
+
def redirect(self, path: str, *, request_id: Optional[str] = None) -> str:
|
|
215
|
+
raw = self._send("GET", self._url(path), follow=False, request_id=request_id)
|
|
216
|
+
if not raw.location:
|
|
217
|
+
raise LowcoApiError("The API answered without a Location", status=raw.status, reason="internal_error")
|
|
218
|
+
return raw.location
|
|
219
|
+
|
|
220
|
+
def download(self, url: str) -> bytes:
|
|
221
|
+
raw = self._send("GET", url, auth=False, headers={"Accept": "*/*"})
|
|
222
|
+
if raw.content is not None:
|
|
223
|
+
return raw.content
|
|
224
|
+
return _json_bytes(raw.body)
|
|
225
|
+
|
|
226
|
+
def operation(self, path: str, params: Dict[str, Any], *, input_kind: str, extra: List[str],
|
|
227
|
+
mode: Optional[str] = None, idempotency_key: Optional[str] = None, accept: Optional[str] = None,
|
|
228
|
+
request_id: Optional[str] = None, timeout: Optional[float] = None) -> Answer[Any]:
|
|
229
|
+
rest = {k: v for k, v in params.items() if v is not None}
|
|
230
|
+
named: List[Tuple[str, FileRef]] = []
|
|
231
|
+
many: Optional[List[FileRef]] = None
|
|
232
|
+
if input_kind == "file":
|
|
233
|
+
if "file" not in rest:
|
|
234
|
+
raise LowcoApiError("file is required", status=0, reason="missing_param", param="file")
|
|
235
|
+
named.append(("file", _normalise(rest.pop("file"))))
|
|
236
|
+
elif input_kind == "files":
|
|
237
|
+
files = rest.pop("files", None)
|
|
238
|
+
if not files:
|
|
239
|
+
raise LowcoApiError("files is required", status=0, reason="missing_param", param="files")
|
|
240
|
+
many = [_normalise(f) for f in files]
|
|
241
|
+
for x in extra:
|
|
242
|
+
if x in rest:
|
|
243
|
+
named.append((x, _normalise(rest.pop(x))))
|
|
244
|
+
refs = [r for _, r in named] + (many or [])
|
|
245
|
+
local = sum(1 for r in refs if r.is_local)
|
|
246
|
+
if 0 < local < len(refs):
|
|
247
|
+
def up(r: FileRef) -> FileRef:
|
|
248
|
+
if not r.is_local:
|
|
249
|
+
return r
|
|
250
|
+
stored = self.upload("/v1/files", r)
|
|
251
|
+
return Files.id(stored["file_id"], password=r.password)
|
|
252
|
+
named = [(f, up(r)) for f, r in named]
|
|
253
|
+
many = [up(r) for r in many] if many is not None else None
|
|
254
|
+
headers: Dict[str, str] = {}
|
|
255
|
+
if accept:
|
|
256
|
+
headers["Accept"] = accept
|
|
257
|
+
if local and local == len(refs):
|
|
258
|
+
fields: List[Tuple[str, str]] = []
|
|
259
|
+
parts: List[Tuple[str, str, str, bytes]] = []
|
|
260
|
+
for fname, r in named:
|
|
261
|
+
name, mime, data = r.read()
|
|
262
|
+
parts.append((fname, name, mime, data))
|
|
263
|
+
if fname == "file" and r.password:
|
|
264
|
+
fields.append(("password", r.password))
|
|
265
|
+
for r in many or []:
|
|
266
|
+
name, mime, data = r.read()
|
|
267
|
+
parts.append(("files[]", name, mime, data))
|
|
268
|
+
if "callback_url" in rest:
|
|
269
|
+
fields.append(("callback_url", str(rest.pop("callback_url"))))
|
|
270
|
+
fields.append(("options", json.dumps(rest)))
|
|
271
|
+
body, ctype = _multipart(fields, parts)
|
|
272
|
+
headers["Content-Type"] = ctype
|
|
273
|
+
else:
|
|
274
|
+
payload = dict(rest)
|
|
275
|
+
for fname, r in named:
|
|
276
|
+
if fname == "file":
|
|
277
|
+
payload.update(r.reference())
|
|
278
|
+
else:
|
|
279
|
+
payload[fname] = r.reference()
|
|
280
|
+
if many is not None:
|
|
281
|
+
payload["files"] = [r.reference() for r in many]
|
|
282
|
+
body = _json_bytes(payload)
|
|
283
|
+
headers["Content-Type"] = "application/json"
|
|
284
|
+
raw = self._send("POST", self._url(path, {"mode": mode} if mode else None), body=body, headers=headers,
|
|
285
|
+
idempotency_key=idempotency_key, request_id=request_id, timeout=timeout, retry_safe=False)
|
|
286
|
+
answer: Answer[Any] = Answer(status=raw.status, request_id=raw.headers.get("x-request-id"),
|
|
287
|
+
replayed=raw.headers.get("idempotent-replayed") == "true",
|
|
288
|
+
credits_used=raw.headers.get("x-lowco-credits-used"))
|
|
289
|
+
if raw.content is not None:
|
|
290
|
+
answer.bytes = raw.content
|
|
291
|
+
answer.content_type = raw.content_type
|
|
292
|
+
elif raw.status == 202:
|
|
293
|
+
answer.job = self._data(raw)
|
|
294
|
+
else:
|
|
295
|
+
answer.data = self._data(raw)
|
|
296
|
+
return answer
|
|
297
|
+
|
|
298
|
+
def paginate(self, path: str, query: Mapping[str, Any], *, request_id: Optional[str] = None) -> Iterator[Any]:
|
|
299
|
+
cursor: Optional[str] = None
|
|
300
|
+
while True:
|
|
301
|
+
page = self.call("GET", path, query={**query, "cursor": cursor}, request_id=request_id)
|
|
302
|
+
for item in page["items"]:
|
|
303
|
+
yield item
|
|
304
|
+
cursor = page.get("next_cursor")
|
|
305
|
+
if not cursor:
|
|
306
|
+
return
|
|
307
|
+
|
|
308
|
+
def wait_for_job(self, job_id: str, *, timeout: float = 300.0, max_interval: float = 10.0) -> Dict[str, Any]:
|
|
309
|
+
deadline = time.monotonic() + timeout
|
|
310
|
+
while True:
|
|
311
|
+
raw = self._send("GET", self._url(f"/v1/jobs/{parse.quote(job_id, safe='')}"))
|
|
312
|
+
job = self._data(raw)
|
|
313
|
+
if job["status"] in _TERMINAL:
|
|
314
|
+
if job["status"] == "succeeded":
|
|
315
|
+
return job # type: ignore[no-any-return]
|
|
316
|
+
e = job.get("error") or {}
|
|
317
|
+
raise LowcoJobError(
|
|
318
|
+
e.get("message") or f"Job {job_id} was {job['status']}", job=job, status=e.get("code") or 409,
|
|
319
|
+
reason=e.get("reason") or "invalid_state", param=e.get("param"), details=e.get("details"),
|
|
320
|
+
doc_url=e.get("doc_url"), retryable=e.get("retryable"), request_id=raw.headers.get("x-request-id"),
|
|
321
|
+
)
|
|
322
|
+
ra = raw.headers.get("retry-after", "2")
|
|
323
|
+
interval = float(ra) if ra.replace(".", "", 1).isdigit() else 2.0
|
|
324
|
+
left = deadline - time.monotonic()
|
|
325
|
+
if left <= 0:
|
|
326
|
+
raise LowcoTimeoutError(job_id, timeout)
|
|
327
|
+
time.sleep(max(0.0, min(interval, max_interval, left)))
|
|
328
|
+
|
|
329
|
+
def wait_for_result(self, answer: Answer[T], *, timeout: float = 300.0, max_interval: float = 10.0) -> T:
|
|
330
|
+
if answer.data is not None:
|
|
331
|
+
return answer.data
|
|
332
|
+
if answer.job is not None:
|
|
333
|
+
job = self.wait_for_job(answer.job["job_id"], timeout=timeout, max_interval=max_interval)
|
|
334
|
+
return job["result"] # type: ignore[no-any-return]
|
|
335
|
+
raise LowcoApiError("The answer carries bytes, not a result", status=answer.status, reason="invalid_param")
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _json_bytes(value: Any) -> bytes:
|
|
339
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def guess_mime(name: str) -> str:
|
|
343
|
+
return mimetypes.guess_type(name)[0] or "application/octet-stream"
|
lowcoai/api/_errors.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
__all__ = ["LowcoApiError", "LowcoJobError", "LowcoTimeoutError"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LowcoApiError(Exception):
|
|
9
|
+
"""Raised for every failed call: an error envelope, a network failure, a
|
|
10
|
+
timeout or invalid input.
|
|
11
|
+
|
|
12
|
+
* ``status`` — the HTTP status, or ``0`` when no response was received.
|
|
13
|
+
* ``reason`` — the stable error code (``error.reason``): branch on this,
|
|
14
|
+
not on ``message``. ``lowcoai.api.ERROR_REASONS`` describes each one.
|
|
15
|
+
* ``param`` — the parameter at fault; ``details`` — each failing item.
|
|
16
|
+
* ``request_id`` — quote it to support; ``doc_url`` — the reason's entry
|
|
17
|
+
on docs.lowco.ai/api/errors.
|
|
18
|
+
* ``retryable`` — whether the same request may succeed later;
|
|
19
|
+
``retry_after`` — seconds to wait (Retry-After) when the API said.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, message: str, *, status: int, reason: str, param: Optional[str] = None,
|
|
23
|
+
details: Optional[List[Dict[str, Any]]] = None, request_id: Optional[str] = None,
|
|
24
|
+
doc_url: Optional[str] = None, retryable: Optional[bool] = None,
|
|
25
|
+
retry_after: Optional[int] = None, payload: Any = None) -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.message = message
|
|
28
|
+
self.status = status
|
|
29
|
+
self.reason = reason
|
|
30
|
+
self.param = param
|
|
31
|
+
self.details = details
|
|
32
|
+
self.request_id = request_id
|
|
33
|
+
self.doc_url = doc_url
|
|
34
|
+
self.retryable = retryable
|
|
35
|
+
self.retry_after = retry_after
|
|
36
|
+
self.payload = payload
|
|
37
|
+
|
|
38
|
+
def __repr__(self) -> str:
|
|
39
|
+
return f"{type(self).__name__}(status={self.status!r}, reason={self.reason!r}, message={self.message!r}, request_id={self.request_id!r})"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class LowcoJobError(LowcoApiError):
|
|
43
|
+
"""Raised by ``jobs.wait`` when a job failed or was cancelled; ``job`` is its last state."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, message: str, *, job: Dict[str, Any], **kwargs: Any) -> None:
|
|
46
|
+
super().__init__(message, payload=job, **kwargs)
|
|
47
|
+
self.job = job
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class LowcoTimeoutError(Exception):
|
|
51
|
+
"""Raised by ``jobs.wait`` when the job is still running at the timeout."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, job_id: str, timeout: float) -> None:
|
|
54
|
+
super().__init__(f"Job {job_id} did not finish within {timeout} s")
|
|
55
|
+
self.job_id = job_id
|
lowcoai/api/_files.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import mimetypes
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import IO, Any, Dict, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
__all__ = ["FileRef", "Files"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class FileRef:
|
|
13
|
+
"""One file input. Local inputs (path, bytes, stream) are sent as multipart
|
|
14
|
+
parts; remote ones (url, id, base64) by reference."""
|
|
15
|
+
|
|
16
|
+
kind: str
|
|
17
|
+
value: Any
|
|
18
|
+
filename: Optional[str] = None
|
|
19
|
+
mime_type: Optional[str] = None
|
|
20
|
+
password: Optional[str] = None
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def is_local(self) -> bool:
|
|
24
|
+
return self.kind in ("path", "bytes", "stream")
|
|
25
|
+
|
|
26
|
+
def read(self) -> Tuple[str, str, bytes]:
|
|
27
|
+
if self.kind == "path":
|
|
28
|
+
with open(self.value, "rb") as f:
|
|
29
|
+
data = f.read()
|
|
30
|
+
name = self.filename or os.path.basename(self.value)
|
|
31
|
+
elif self.kind == "bytes":
|
|
32
|
+
data, name = bytes(self.value), self.filename or "file"
|
|
33
|
+
elif self.kind == "stream":
|
|
34
|
+
data = self.value.read()
|
|
35
|
+
name = self.filename or os.path.basename(getattr(self.value, "name", "") or "file")
|
|
36
|
+
else:
|
|
37
|
+
raise ValueError(f"a {self.kind} input is not local")
|
|
38
|
+
mime = self.mime_type or mimetypes.guess_type(name)[0] or "application/octet-stream"
|
|
39
|
+
return name, mime, data
|
|
40
|
+
|
|
41
|
+
def reference(self) -> Dict[str, Any]:
|
|
42
|
+
if self.kind == "url":
|
|
43
|
+
out: Dict[str, Any] = {"file_url": self.value}
|
|
44
|
+
elif self.kind == "id":
|
|
45
|
+
out = {"file_id": self.value}
|
|
46
|
+
elif self.kind == "base64":
|
|
47
|
+
out = {"file_base64": self.value}
|
|
48
|
+
if self.filename:
|
|
49
|
+
out["filename"] = self.filename
|
|
50
|
+
else:
|
|
51
|
+
raise ValueError(f"a {self.kind} input must be uploaded")
|
|
52
|
+
if self.password:
|
|
53
|
+
out["password"] = self.password
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Files:
|
|
58
|
+
"""Builders for file inputs."""
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def path(path: "str | os.PathLike[str]", *, filename: Optional[str] = None, mime_type: Optional[str] = None,
|
|
62
|
+
password: Optional[str] = None) -> FileRef:
|
|
63
|
+
"""A local file by path (read when the call is made)."""
|
|
64
|
+
return FileRef("path", os.fspath(path), filename, mime_type, password)
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def bytes(data: "bytes | bytearray | memoryview", *, filename: Optional[str] = None, mime_type: Optional[str] = None,
|
|
68
|
+
password: Optional[str] = None) -> FileRef:
|
|
69
|
+
"""Bytes in memory."""
|
|
70
|
+
return FileRef("bytes", bytes(data), filename, mime_type, password)
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def stream(stream: IO[bytes], *, filename: Optional[str] = None, mime_type: Optional[str] = None,
|
|
74
|
+
password: Optional[str] = None) -> FileRef:
|
|
75
|
+
"""A binary file object, read to the end when the call is made."""
|
|
76
|
+
return FileRef("stream", stream, filename, mime_type, password)
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def url(url: str, *, password: Optional[str] = None) -> FileRef:
|
|
80
|
+
"""A public https URL the API fetches (file_url)."""
|
|
81
|
+
return FileRef("url", url, password=password)
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def id(file_id: str, *, password: Optional[str] = None) -> FileRef:
|
|
85
|
+
"""A stored file (file_id from files.upload or an earlier output)."""
|
|
86
|
+
return FileRef("id", file_id, password=password)
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def base64(data: str, *, filename: Optional[str] = None, password: Optional[str] = None) -> FileRef:
|
|
90
|
+
"""Base64 or a data: URL (up to 5 MB decoded)."""
|
|
91
|
+
return FileRef("base64", data, filename, password=password)
|