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.
- lowcoai_api-1.1.0/.gitignore +17 -0
- lowcoai_api-1.1.0/CHANGELOG.md +7 -0
- lowcoai_api-1.1.0/LICENSE +21 -0
- lowcoai_api-1.1.0/PKG-INFO +78 -0
- lowcoai_api-1.1.0/README.md +60 -0
- lowcoai_api-1.1.0/examples/quickstart.py +28 -0
- lowcoai_api-1.1.0/pyproject.toml +40 -0
- lowcoai_api-1.1.0/src/lowcoai/api/__init__.py +47 -0
- lowcoai_api-1.1.0/src/lowcoai/api/_core.py +343 -0
- lowcoai_api-1.1.0/src/lowcoai/api/_errors.py +55 -0
- lowcoai_api-1.1.0/src/lowcoai/api/_files.py +91 -0
- lowcoai_api-1.1.0/src/lowcoai/api/_generated.py +7140 -0
- lowcoai_api-1.1.0/src/lowcoai/api/_webhooks.py +54 -0
- lowcoai_api-1.1.0/src/lowcoai/api/py.typed +0 -0
- lowcoai_api-1.1.0/tests/__init__.py +0 -0
- lowcoai_api-1.1.0/tests/test_api.py +240 -0
|
@@ -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"
|