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.
@@ -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)