lowcoai-api 1.1.0__tar.gz

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,17 @@
1
+ node_modules
2
+ npm/engage/dist
3
+ dist
4
+
5
+ # Python
6
+ __pycache__/
7
+ *.py[cod]
8
+ *.egg-info/
9
+ .venv/
10
+ .pytest_cache/
11
+ .mypy_cache/
12
+ .ruff_cache/
13
+ build/
14
+
15
+ # Dart / Flutter
16
+ .dart_tool/
17
+ pubspec.lock
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 1.1.0
4
+
5
+ - First release, generated from Lowco API 1.1.0: every Phase 1 operation (PDF, render, OCR, Document AI, QR and barcodes) and resource (files, jobs, webhooks, templates, settings, usage, audit log).
6
+ - File inputs from paths, bytes, streams, URLs, file ids and base64; multipart or references chosen per call.
7
+ - `wait_for_result` and `jobs.wait` (Retry-After, timeout), `list_all` iterators, `LowcoApiError` with typed `reason`, retries on 429/503, `new_idempotency_key`, raw bytes with `accept`, webhook `verify_signature` and `construct_event`; standard library only.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lowco AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.5
2
+ Name: lowcoai-api
3
+ Version: 1.1.0
4
+ Summary: Official Python client for the Lowco API: PDF, render, OCR, Document AI, QR and barcodes, with jobs, file inputs, webhooks and typed errors.
5
+ Project-URL: Repository, https://github.com/lowcoai/lowco-sdk
6
+ Project-URL: Source, https://github.com/lowcoai/lowco-sdk/tree/main/python/api
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: document-ai,gst,invoice,lowco,ocr,pdf,qr,sdk,upi
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Typing :: Typed
12
+ Requires-Python: >=3.9
13
+ Requires-Dist: typing-extensions>=4.7; python_version < '3.11'
14
+ Provides-Extra: dev
15
+ Requires-Dist: mypy>=1.10; extra == 'dev'
16
+ Requires-Dist: ruff>=0.5; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # lowcoai-api
20
+
21
+ Official Python client for the **Lowco API**: PDF, render, OCR, Document AI, QR codes and barcodes, with jobs, file inputs, webhooks and typed errors. Generated from the API's OpenAPI document with hand-written helpers. Python 3.9+, standard library only.
22
+
23
+ ```bash
24
+ pip install lowcoai-api
25
+ ```
26
+
27
+ ## Quick start
28
+
29
+ ```python
30
+ from lowcoai.api import Files, LowcoApi
31
+
32
+ lowco = LowcoApi() # LOWCO_API_KEY: lc_test_… or lc_live_…
33
+
34
+ qr = lowco.qr.generate(type="upi", pa="acme@okhdfcbank", pn="Acme Traders", am="1499.00", accept="image/png")
35
+ open("qr.png", "wb").write(qr.bytes)
36
+
37
+ answer = lowco.docai.invoice(file=Files.path("invoice.pdf"), mode="async") # 202 with a job
38
+ invoice = lowco.wait_for_result(answer) # polls, honouring Retry-After
39
+ print(invoice["invoice_number"]["value"], invoice["invoice_number"]["confidence"])
40
+ ```
41
+
42
+ `examples/quickstart.py` is the full program; CI runs it against the test environment.
43
+
44
+ ## Operations
45
+
46
+ `lowco.<service>.<operation>(**params)` for every operation: `pdf` (merge, split, arrange, compress, rotate, watermark, protect, unlock, to_images, from_images, info, extract_text, to_docx), `render` (html, template, docx, docx_template), `ocr` (text, handwriting), `docai` (classify, invoice, receipt, tables, extract), `qr` (generate, decode), `barcode` (generate, decode). Parameters are keyword-only and named like the API's fields; results are typed dicts (`PdfMergeResult`, …).
47
+
48
+ Each returns an `Answer`: `data` when finished in the request, `job` when accepted as one (status 202), `bytes`/`content_type` when `accept` asked for the output's own type; plus `request_id`, `replayed` and `credits_used`. Every operation also takes `mode`, `accept`, `idempotency_key`, `request_id` and `timeout`.
49
+
50
+ ## Files
51
+
52
+ `Files.path(p, password=…)`, `Files.bytes(b, filename=…)`, `Files.stream(fileobj)`, `Files.url(u)`, `Files.id("file_…")`, `Files.base64(s)`; bytes and binary file objects are accepted bare. Local inputs go as multipart parts, remote ones by reference; mixed calls upload the local ones first. `lowco.files.download(url)` fetches an output's signed link.
53
+
54
+ ## Jobs, pagination, errors
55
+
56
+ ```python
57
+ job = lowco.jobs.wait("job_01J…", timeout=120) # LowcoJobError if it failed, LowcoTimeoutError at the timeout
58
+ for j in lowco.jobs.list_all(status="failed"): ... # follows next_cursor
59
+
60
+ try:
61
+ ...
62
+ except LowcoApiError as e:
63
+ e.reason, e.status, e.param, e.details, e.request_id, e.doc_url, e.retryable, e.retry_after
64
+ ```
65
+
66
+ `ERROR_REASONS` says when each reason is answered and what to do. GETs and calls with an `idempotency_key` (`new_idempotency_key()`) are retried on 429, 503 and network failures. A parameter named like a Python keyword takes a trailing underscore (`usage.get(from_="2026-10-01")`).
67
+
68
+ ## Webhooks
69
+
70
+ ```python
71
+ from lowcoai.api import construct_event
72
+
73
+ event = construct_event(raw_body, request.headers.get("Lowco-Signature"), secret) # raises LowcoSignatureError
74
+ ```
75
+
76
+ ## Configuration
77
+
78
+ `LowcoApi(api_key=None, *, base_url=None, timeout=60.0, max_retries=2)`; the key defaults to `LOWCO_API_KEY` and the base URL to `LOWCO_API_BASE_URL`, else `https://api.lowco.ai`. Tests: `python -m unittest discover -s tests`. Reference: https://docs.lowco.ai/api/
@@ -0,0 +1,60 @@
1
+ # lowcoai-api
2
+
3
+ Official Python client for the **Lowco API**: PDF, render, OCR, Document AI, QR codes and barcodes, with jobs, file inputs, webhooks and typed errors. Generated from the API's OpenAPI document with hand-written helpers. Python 3.9+, standard library only.
4
+
5
+ ```bash
6
+ pip install lowcoai-api
7
+ ```
8
+
9
+ ## Quick start
10
+
11
+ ```python
12
+ from lowcoai.api import Files, LowcoApi
13
+
14
+ lowco = LowcoApi() # LOWCO_API_KEY: lc_test_… or lc_live_…
15
+
16
+ qr = lowco.qr.generate(type="upi", pa="acme@okhdfcbank", pn="Acme Traders", am="1499.00", accept="image/png")
17
+ open("qr.png", "wb").write(qr.bytes)
18
+
19
+ answer = lowco.docai.invoice(file=Files.path("invoice.pdf"), mode="async") # 202 with a job
20
+ invoice = lowco.wait_for_result(answer) # polls, honouring Retry-After
21
+ print(invoice["invoice_number"]["value"], invoice["invoice_number"]["confidence"])
22
+ ```
23
+
24
+ `examples/quickstart.py` is the full program; CI runs it against the test environment.
25
+
26
+ ## Operations
27
+
28
+ `lowco.<service>.<operation>(**params)` for every operation: `pdf` (merge, split, arrange, compress, rotate, watermark, protect, unlock, to_images, from_images, info, extract_text, to_docx), `render` (html, template, docx, docx_template), `ocr` (text, handwriting), `docai` (classify, invoice, receipt, tables, extract), `qr` (generate, decode), `barcode` (generate, decode). Parameters are keyword-only and named like the API's fields; results are typed dicts (`PdfMergeResult`, …).
29
+
30
+ Each returns an `Answer`: `data` when finished in the request, `job` when accepted as one (status 202), `bytes`/`content_type` when `accept` asked for the output's own type; plus `request_id`, `replayed` and `credits_used`. Every operation also takes `mode`, `accept`, `idempotency_key`, `request_id` and `timeout`.
31
+
32
+ ## Files
33
+
34
+ `Files.path(p, password=…)`, `Files.bytes(b, filename=…)`, `Files.stream(fileobj)`, `Files.url(u)`, `Files.id("file_…")`, `Files.base64(s)`; bytes and binary file objects are accepted bare. Local inputs go as multipart parts, remote ones by reference; mixed calls upload the local ones first. `lowco.files.download(url)` fetches an output's signed link.
35
+
36
+ ## Jobs, pagination, errors
37
+
38
+ ```python
39
+ job = lowco.jobs.wait("job_01J…", timeout=120) # LowcoJobError if it failed, LowcoTimeoutError at the timeout
40
+ for j in lowco.jobs.list_all(status="failed"): ... # follows next_cursor
41
+
42
+ try:
43
+ ...
44
+ except LowcoApiError as e:
45
+ e.reason, e.status, e.param, e.details, e.request_id, e.doc_url, e.retryable, e.retry_after
46
+ ```
47
+
48
+ `ERROR_REASONS` says when each reason is answered and what to do. GETs and calls with an `idempotency_key` (`new_idempotency_key()`) are retried on 429, 503 and network failures. A parameter named like a Python keyword takes a trailing underscore (`usage.get(from_="2026-10-01")`).
49
+
50
+ ## Webhooks
51
+
52
+ ```python
53
+ from lowcoai.api import construct_event
54
+
55
+ event = construct_event(raw_body, request.headers.get("Lowco-Signature"), secret) # raises LowcoSignatureError
56
+ ```
57
+
58
+ ## Configuration
59
+
60
+ `LowcoApi(api_key=None, *, base_url=None, timeout=60.0, max_retries=2)`; the key defaults to `LOWCO_API_KEY` and the base URL to `LOWCO_API_BASE_URL`, else `https://api.lowco.ai`. Tests: `python -m unittest discover -s tests`. Reference: https://docs.lowco.ai/api/
@@ -0,0 +1,28 @@
1
+ """The Lowco API quickstart with lowcoai-api: a UPI QR code, then a GST
2
+ invoice extracted as a job and waited for. Run with LOWCO_API_KEY set to a
3
+ test key (lc_test_…) and invoice.pdf in the working directory."""
4
+
5
+ import sys
6
+
7
+ from lowcoai.api import Files, LowcoApi, LowcoApiError
8
+
9
+ lowco = LowcoApi() # reads LOWCO_API_KEY
10
+
11
+ try:
12
+ # 1. A UPI payment QR code, as the PNG itself
13
+ qr = lowco.qr.generate(type="upi", pa="acme@okhdfcbank", pn="Acme Traders", am="1499.00",
14
+ tn="INV-2026-0042", accept="image/png")
15
+ with open("qr.png", "wb") as f:
16
+ f.write(qr.bytes)
17
+ print(f"qr.png written ({len(qr.bytes)} bytes)")
18
+
19
+ # 2. A GST invoice, submitted as a job and waited for
20
+ answer = lowco.docai.invoice(file=Files.path("invoice.pdf"), mode="async")
21
+ print(f"job {answer.job['status']}")
22
+ invoice = lowco.wait_for_result(answer)
23
+ for name, f in {"invoice_number": invoice["invoice_number"], "seller_gstin": invoice["seller"]["gstin"],
24
+ "total": invoice["total"]}.items():
25
+ print(f"{name}: {f['value']} (confidence {f['confidence']})")
26
+ except LowcoApiError as e:
27
+ print(f"{e.reason}: {e.message} ({e.doc_url}, request {e.request_id})", file=sys.stderr)
28
+ sys.exit(1)
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.21"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "lowcoai-api"
7
+ version = "1.1.0"
8
+ description = "Official Python client for the Lowco API: PDF, render, OCR, Document AI, QR and barcodes, with jobs, file inputs, webhooks and typed errors."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ dependencies = [
13
+ "typing-extensions>=4.7; python_version < '3.11'",
14
+ ]
15
+ keywords = ["lowco", "pdf", "ocr", "document-ai", "invoice", "gst", "qr", "upi", "sdk"]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "Typing :: Typed",
19
+ ]
20
+
21
+ [project.urls]
22
+ Repository = "https://github.com/lowcoai/lowco-sdk"
23
+ Source = "https://github.com/lowcoai/lowco-sdk/tree/main/python/api"
24
+
25
+ [project.optional-dependencies]
26
+ dev = ["mypy>=1.10", "ruff>=0.5"]
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/lowcoai"]
30
+
31
+ [tool.mypy]
32
+ strict = true
33
+ mypy_path = "src"
34
+ explicit_package_bases = true
35
+ warn_return_any = false
36
+
37
+ [tool.ruff]
38
+ line-length = 120
39
+ target-version = "py39"
40
+ extend-exclude = ["src/lowcoai/api/_generated.py"]
@@ -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)
@@ -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"