imazeo 1.0.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.
- imazeo/GUIDE.md +141 -0
- imazeo/__init__.py +75 -0
- imazeo/_http.py +437 -0
- imazeo/client.py +86 -0
- imazeo/errors.py +89 -0
- imazeo/openapi/backend.json +6348 -0
- imazeo/resources/__init__.py +17 -0
- imazeo/resources/barcodes.py +30 -0
- imazeo/resources/context.py +17 -0
- imazeo/resources/images.py +86 -0
- imazeo/resources/pdf.py +163 -0
- imazeo/resources/qr.py +30 -0
- imazeo/resources/transforms.py +91 -0
- imazeo/types.py +266 -0
- imazeo/version.py +14 -0
- imazeo-1.0.0.dist-info/METADATA +210 -0
- imazeo-1.0.0.dist-info/RECORD +19 -0
- imazeo-1.0.0.dist-info/WHEEL +4 -0
- imazeo-1.0.0.dist-info/licenses/LICENSE +21 -0
imazeo/GUIDE.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Imazeo Python integration guide
|
|
2
|
+
|
|
3
|
+
## 1. Prepare a server integration
|
|
4
|
+
|
|
5
|
+
Install the package:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install imazeo
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Package: [`imazeo`](https://pypi.org/project/imazeo/) on PyPI.
|
|
12
|
+
Source: [manishbalodia/imazeo-python](https://github.com/manishbalodia/imazeo-python).
|
|
13
|
+
Use the package README for runtime details.
|
|
14
|
+
|
|
15
|
+
Create a Customer account, complete required verification, create a project and
|
|
16
|
+
issue a project key. Set IMAZEO_API_KEY in protected server configuration and
|
|
17
|
+
IMAZEO_API_ORIGIN to the supplied API origin (no /v1 suffix; production default
|
|
18
|
+
`https://api.imazeo.com`). Use HTTPS outside loopback development. A key can
|
|
19
|
+
resolve its project through GET /v1/api-key/context; that response has projectId
|
|
20
|
+
and keyId, not plan limits or the secret key.
|
|
21
|
+
|
|
22
|
+
The Main site's documentation paths, served by your configured Main origin, are:
|
|
23
|
+
/docs/getting-started/, /docs/authentication/ and /docs/projects/.
|
|
24
|
+
These paths are site-relative; this guide does not assume a live production domain.
|
|
25
|
+
|
|
26
|
+
## 2. Generate a QR code and render a delivery note
|
|
27
|
+
|
|
28
|
+
The following example uses the `imazeo` package. Obtain the original two-page synthetic
|
|
29
|
+
delivery-note.pdf from the Main site's /examples/assets/delivery-note.pdf and put it
|
|
30
|
+
in the working directory. The event URL is fictional. Run only against your configured
|
|
31
|
+
API deployment with the required entitlements and credits.
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import os
|
|
35
|
+
import base64
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from imazeo import Imazeo, PdfMultiPageResult
|
|
38
|
+
|
|
39
|
+
# Context manager closes the underlying HTTP client.
|
|
40
|
+
with Imazeo(api_key=os.environ['IMAZEO_API_KEY'],
|
|
41
|
+
base_url=os.environ['IMAZEO_API_ORIGIN']) as client:
|
|
42
|
+
qr = client.qr.generate({
|
|
43
|
+
'content': 'https://example.com/events/studio-open-day',
|
|
44
|
+
'format': 'svg', 'size': 384,
|
|
45
|
+
})
|
|
46
|
+
Path('event-check-in.svg').write_bytes(qr.data)
|
|
47
|
+
result = client.pdf.render(Path('delivery-note.pdf').read_bytes(), {
|
|
48
|
+
'pages': '1,2', 'format': 'png',
|
|
49
|
+
})
|
|
50
|
+
if isinstance(result, PdfMultiPageResult):
|
|
51
|
+
for page in result.pages:
|
|
52
|
+
Path(f"delivery-page-{page['pageNumber']}.png").write_bytes(
|
|
53
|
+
base64.b64decode(page['data'], validate=True))
|
|
54
|
+
else:
|
|
55
|
+
Path('delivery-page.png').write_bytes(result.data)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
All six SDK default transports refuse HTTP redirects so project credentials stay
|
|
59
|
+
on the configured API origin. The examples also set safe redirect policies where
|
|
60
|
+
custom client hooks are used. Custom fetch functions, Java HttpClient instances,
|
|
61
|
+
.NET HttpClient/message handlers and Python/PHP handlers must preserve this rule.
|
|
62
|
+
Never forward project credentials to a redirected host.
|
|
63
|
+
|
|
64
|
+
QR success is binary SVG. A one-page PDF render returns binary image bytes; multiple
|
|
65
|
+
selected pages return base64 page data. This SDK's render result is:
|
|
66
|
+
BinaryResult or PdfMultiPageResult. Decode base64 exactly once; use each page's contentType if you
|
|
67
|
+
change the selected format. The example uses PNG and .png filenames deliberately.
|
|
68
|
+
|
|
69
|
+
## 3. Upload and transform images
|
|
70
|
+
|
|
71
|
+
Use the image upload method with file bytes and explicit filename/content type
|
|
72
|
+
as documented in the README. It sends multipart field file and the exact
|
|
73
|
+
X-Upload-Length file byte count. Filenames are metadata, not paths or resource IDs.
|
|
74
|
+
Keep the returned image ID, then create a transform with imageId and transform.
|
|
75
|
+
REST uses _id; use the native ID accessor shown in the README.
|
|
76
|
+
|
|
77
|
+
CACHE and PERSIST retain derivatives according to their mode rules. EPHEMERAL
|
|
78
|
+
only returns immediate bytes. All transform success bodies are binary. Image
|
|
79
|
+
originals preserve original metadata; generated derivatives strip metadata.
|
|
80
|
+
The HTTP guides are /docs/images/, /docs/transforms/ and /docs/quickstart/.
|
|
81
|
+
|
|
82
|
+
## 4. Choose PDF and code inputs
|
|
83
|
+
|
|
84
|
+
PDF info returns parsed JSON and costs one credit. Render costs one per selected
|
|
85
|
+
page; merge costs two; extract costs one. Page numbers are one-based. Select
|
|
86
|
+
pages, a complete rangeStart/rangeEnd pair, or render-only all=true; do not combine
|
|
87
|
+
selectors. Render defaults to page 1; extract requires an explicit selection.
|
|
88
|
+
Merge upload order determines document order. Encrypted/invalid PDFs are rejected.
|
|
89
|
+
|
|
90
|
+
QR and barcode generation each cost one credit and return PNG or SVG. Barcode
|
|
91
|
+
content must be a string, including numeric identifiers with leading zeros.
|
|
92
|
+
Send numbers and booleans as native values, and omit unused optional fields instead
|
|
93
|
+
of null. A barcode renderer does not assign registered retail identifiers; QR
|
|
94
|
+
generation does not provide dynamic redirect hosting.
|
|
95
|
+
Detailed guides: /docs/pdf/, /docs/qr/, /docs/barcodes/ and /docs/workflows/.
|
|
96
|
+
|
|
97
|
+
## 5. Handle pagination, downloads and errors
|
|
98
|
+
|
|
99
|
+
Image/transform lists return a Page wrapper with items and page/limit/total/pages.
|
|
100
|
+
Pagination is explicit; clients do not silently fetch all pages. Only image lists
|
|
101
|
+
accept q as a filename-prefix filter; transformation lists accept page and limit.
|
|
102
|
+
This is not unrestricted full-text search.
|
|
103
|
+
Binary results contain bytes, content type, headers and request ID. Derivative
|
|
104
|
+
downloads support conditional ETags. A not-modified result has no new bytes:
|
|
105
|
+
retain your cached file instead of replacing it with an empty body.
|
|
106
|
+
|
|
107
|
+
SDK errors expose status, code, request ID and details using native language names
|
|
108
|
+
listed in the README. Network errors do not guarantee an HTTP status. Log only
|
|
109
|
+
sanitized diagnostics; never log a key, source media or base64 PDF pages. Fix
|
|
110
|
+
validation/authentication/entitlement failures. For temporary 429 or busy responses,
|
|
111
|
+
use bounded backoff only when replay is safe and honor Retry-After if supplied.
|
|
112
|
+
No general idempotency-key mechanism is promised. A timed-out mutation may already
|
|
113
|
+
have completed; reconcile known IDs/state before repeating it. SDKs do not
|
|
114
|
+
automatically retry mutations.
|
|
115
|
+
See /docs/errors/ and /docs/troubleshooting/.
|
|
116
|
+
|
|
117
|
+
## 6. Retain outputs and operate in production
|
|
118
|
+
|
|
119
|
+
PDF/code output is transient unless you retain the bytes or separately save a
|
|
120
|
+
private artifact through HTTP. Saved artifacts, unified output inventory, usage,
|
|
121
|
+
account/project/key management and Admin APIs are outside the SDK's core media
|
|
122
|
+
scope. Use Customer Bearer authentication for management, not a project key.
|
|
123
|
+
Do not combine Bearer and X-API-Key in one request.
|
|
124
|
+
|
|
125
|
+
There is no public CDN or customer signed-delivery URL in this contract. Deliver
|
|
126
|
+
bytes through your own authorized server. Never bundle a project key into browser,
|
|
127
|
+
mobile or end-user applications. Your server must enforce its own per-user access.
|
|
128
|
+
|
|
129
|
+
Bound source sizes, output bytes, page counts and concurrency: these SDKs buffer
|
|
130
|
+
media and base64 pages require extra memory. Default client timeouts are 30 seconds
|
|
131
|
+
for light operations, 60 seconds for images and 120 seconds for PDFs; they do not
|
|
132
|
+
extend backend worker deadlines. Check assigned account limits, not just public
|
|
133
|
+
pricing templates. Cache hits still consume output bandwidth; an authenticated
|
|
134
|
+
derivative 304 consumes no output bandwidth. Deletion refunds storage, not past
|
|
135
|
+
credits/bandwidth. Expiry and physical cleanup are separate.
|
|
136
|
+
|
|
137
|
+
Production and reference paths: /docs/outputs/, /docs/limits/, /docs/security/,
|
|
138
|
+
/docs/production/ and /docs/api-reference/. The developer OpenAPI download is
|
|
139
|
+
/openapi/imazeo-developer.json. It is a curated development snapshot and excludes
|
|
140
|
+
Admin/account operations and the known defective unified Outputs list; use
|
|
141
|
+
resource-specific lists to enumerate inventory.
|
imazeo/__init__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Official Imazeo Python SDK."""
|
|
2
|
+
|
|
3
|
+
from imazeo.client import Imazeo
|
|
4
|
+
from imazeo.errors import (
|
|
5
|
+
ApiError,
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
AuthorizationError,
|
|
8
|
+
ConflictError,
|
|
9
|
+
ImazeoError,
|
|
10
|
+
NetworkError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
RateLimitError,
|
|
13
|
+
ValidationError,
|
|
14
|
+
)
|
|
15
|
+
from imazeo.types import (
|
|
16
|
+
ApiKeyContext,
|
|
17
|
+
BarcodeGenerateInput,
|
|
18
|
+
BinaryResult,
|
|
19
|
+
ImageRecord,
|
|
20
|
+
Page,
|
|
21
|
+
PageParams,
|
|
22
|
+
PdfExtractOptions,
|
|
23
|
+
PdfInfo,
|
|
24
|
+
PdfMultiPageResult,
|
|
25
|
+
PdfRenderOptions,
|
|
26
|
+
PdfRenderPage,
|
|
27
|
+
QrGenerateInput,
|
|
28
|
+
TransformationRecord,
|
|
29
|
+
TransformRequest,
|
|
30
|
+
TransformSpec,
|
|
31
|
+
NormalizedTransformSpec,
|
|
32
|
+
UploadFile,
|
|
33
|
+
)
|
|
34
|
+
from imazeo.version import (
|
|
35
|
+
DEFAULT_API_VERSION,
|
|
36
|
+
DEFAULT_BASE_URL,
|
|
37
|
+
SDK_VERSION,
|
|
38
|
+
SUPPORTED_API_VERSIONS,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"ApiError",
|
|
43
|
+
"ApiKeyContext",
|
|
44
|
+
"AuthenticationError",
|
|
45
|
+
"AuthorizationError",
|
|
46
|
+
"BarcodeGenerateInput",
|
|
47
|
+
"BinaryResult",
|
|
48
|
+
"ConflictError",
|
|
49
|
+
"DEFAULT_API_VERSION",
|
|
50
|
+
"DEFAULT_BASE_URL",
|
|
51
|
+
"Imazeo",
|
|
52
|
+
"ImazeoError",
|
|
53
|
+
"ImageRecord",
|
|
54
|
+
"NetworkError",
|
|
55
|
+
"NotFoundError",
|
|
56
|
+
"Page",
|
|
57
|
+
"PageParams",
|
|
58
|
+
"PdfExtractOptions",
|
|
59
|
+
"PdfInfo",
|
|
60
|
+
"PdfMultiPageResult",
|
|
61
|
+
"PdfRenderOptions",
|
|
62
|
+
"PdfRenderPage",
|
|
63
|
+
"QrGenerateInput",
|
|
64
|
+
"RateLimitError",
|
|
65
|
+
"SDK_VERSION",
|
|
66
|
+
"SUPPORTED_API_VERSIONS",
|
|
67
|
+
"TransformationRecord",
|
|
68
|
+
"TransformRequest",
|
|
69
|
+
"TransformSpec",
|
|
70
|
+
"NormalizedTransformSpec",
|
|
71
|
+
"UploadFile",
|
|
72
|
+
"ValidationError",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
__version__ = SDK_VERSION
|
imazeo/_http.py
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""HTTP transport for the sync Imazeo client (httpx)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import quote, urlencode, urlparse
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from imazeo.errors import NetworkError, map_http_error
|
|
12
|
+
from imazeo.types import BinaryResult, PreparedUpload, UploadFile, UploadSource
|
|
13
|
+
from imazeo.version import (
|
|
14
|
+
DEFAULT_API_VERSION,
|
|
15
|
+
DEFAULT_BASE_URL,
|
|
16
|
+
DEFAULT_TIMEOUT_MS,
|
|
17
|
+
SDK_VERSION,
|
|
18
|
+
SUPPORTED_API_VERSIONS,
|
|
19
|
+
ApiVersion,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class JsonSuccess:
|
|
25
|
+
data: Any
|
|
26
|
+
meta: dict[str, Any]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class TransportConfig:
|
|
31
|
+
api_key: str
|
|
32
|
+
base_url: str
|
|
33
|
+
api_version: ApiVersion
|
|
34
|
+
timeout_ms: int
|
|
35
|
+
user_agent: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def assert_api_version(value: str) -> ApiVersion:
|
|
39
|
+
if value not in SUPPORTED_API_VERSIONS:
|
|
40
|
+
supported = ", ".join(SUPPORTED_API_VERSIONS)
|
|
41
|
+
raise ValueError(f'Unsupported apiVersion "{value}". Supported: {supported}')
|
|
42
|
+
return value # type: ignore[return-value]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def normalize_base_url(raw: str) -> str:
|
|
46
|
+
try:
|
|
47
|
+
parsed = urlparse(raw)
|
|
48
|
+
except Exception as exc: # pragma: no cover - urlparse rarely raises
|
|
49
|
+
raise ValueError(f"Invalid baseUrl: {raw}") from exc
|
|
50
|
+
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
|
51
|
+
if parsed.scheme not in ("http", "https"):
|
|
52
|
+
raise ValueError("baseUrl must use http or https")
|
|
53
|
+
raise ValueError(f"Invalid baseUrl: {raw}")
|
|
54
|
+
if parsed.username or parsed.password:
|
|
55
|
+
raise ValueError("baseUrl must not include credentials")
|
|
56
|
+
path = parsed.path.rstrip("/")
|
|
57
|
+
if path == "/v1":
|
|
58
|
+
path = ""
|
|
59
|
+
origin = f"{parsed.scheme}://{parsed.netloc}"
|
|
60
|
+
if path and path != "/":
|
|
61
|
+
return f"{origin}{path}".rstrip("/")
|
|
62
|
+
return origin
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def create_transport_config(
|
|
66
|
+
*,
|
|
67
|
+
api_key: str,
|
|
68
|
+
base_url: str | None = None,
|
|
69
|
+
api_version: str | None = None,
|
|
70
|
+
timeout_ms: int | None = None,
|
|
71
|
+
app_name: str | None = None,
|
|
72
|
+
) -> TransportConfig:
|
|
73
|
+
key = (api_key or "").strip()
|
|
74
|
+
if not key:
|
|
75
|
+
raise ValueError("apiKey is required")
|
|
76
|
+
if len(key) > 128:
|
|
77
|
+
raise ValueError("apiKey is too long")
|
|
78
|
+
version = assert_api_version(api_version or DEFAULT_API_VERSION)
|
|
79
|
+
origin = normalize_base_url(base_url or DEFAULT_BASE_URL)
|
|
80
|
+
timeout = DEFAULT_TIMEOUT_MS if timeout_ms is None else timeout_ms
|
|
81
|
+
if not isinstance(timeout, (int, float)) or timeout < 1:
|
|
82
|
+
raise ValueError("timeoutMs must be a positive number")
|
|
83
|
+
ua = (
|
|
84
|
+
f"Imazeo-Python/{SDK_VERSION} {app_name}"
|
|
85
|
+
if app_name
|
|
86
|
+
else f"Imazeo-Python/{SDK_VERSION}"
|
|
87
|
+
)
|
|
88
|
+
return TransportConfig(
|
|
89
|
+
api_key=key,
|
|
90
|
+
base_url=origin,
|
|
91
|
+
api_version=version,
|
|
92
|
+
timeout_ms=int(timeout),
|
|
93
|
+
user_agent=ua[:256],
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def prepare_upload(
|
|
98
|
+
source: UploadSource,
|
|
99
|
+
*,
|
|
100
|
+
filename: str | None = None,
|
|
101
|
+
content_type: str | None = None,
|
|
102
|
+
) -> PreparedUpload:
|
|
103
|
+
if isinstance(source, UploadFile):
|
|
104
|
+
data = bytes(source.data)
|
|
105
|
+
return PreparedUpload(
|
|
106
|
+
data=data,
|
|
107
|
+
filename=source.filename or filename or "file.bin",
|
|
108
|
+
content_type=source.content_type
|
|
109
|
+
or content_type
|
|
110
|
+
or "application/octet-stream",
|
|
111
|
+
)
|
|
112
|
+
if isinstance(source, (bytes, bytearray, memoryview)):
|
|
113
|
+
data = bytes(source)
|
|
114
|
+
return PreparedUpload(
|
|
115
|
+
data=data,
|
|
116
|
+
filename=filename or "file.bin",
|
|
117
|
+
content_type=content_type or "application/octet-stream",
|
|
118
|
+
)
|
|
119
|
+
raise TypeError("Unsupported upload source")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _header_map(headers: httpx.Headers) -> dict[str, str]:
|
|
123
|
+
return {k.lower(): v for k, v in headers.items()}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _num_header(headers: httpx.Headers, name: str) -> int | None:
|
|
127
|
+
raw = headers.get(name)
|
|
128
|
+
if raw is None:
|
|
129
|
+
return None
|
|
130
|
+
try:
|
|
131
|
+
return int(raw)
|
|
132
|
+
except ValueError:
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class HttpTransport:
|
|
137
|
+
"""Sync httpx transport with Imazeo envelope handling."""
|
|
138
|
+
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
config: TransportConfig,
|
|
142
|
+
*,
|
|
143
|
+
transport: httpx.BaseTransport | None = None,
|
|
144
|
+
client: httpx.Client | None = None,
|
|
145
|
+
) -> None:
|
|
146
|
+
self.config = config
|
|
147
|
+
self._owns_client = client is None
|
|
148
|
+
if client is not None:
|
|
149
|
+
self._client = client
|
|
150
|
+
else:
|
|
151
|
+
self._client = httpx.Client(
|
|
152
|
+
transport=transport,
|
|
153
|
+
timeout=httpx.Timeout(config.timeout_ms / 1000.0),
|
|
154
|
+
follow_redirects=False,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def close(self) -> None:
|
|
158
|
+
if self._owns_client:
|
|
159
|
+
self._client.close()
|
|
160
|
+
|
|
161
|
+
def __enter__(self) -> HttpTransport:
|
|
162
|
+
return self
|
|
163
|
+
|
|
164
|
+
def __exit__(self, *args: object) -> None:
|
|
165
|
+
self.close()
|
|
166
|
+
|
|
167
|
+
def path(self, *segments: str) -> str:
|
|
168
|
+
encoded = "/".join(_encode_segment(seg) for seg in segments)
|
|
169
|
+
return f"{self.config.base_url}/{self.config.api_version}/{encoded}"
|
|
170
|
+
|
|
171
|
+
def request_json(
|
|
172
|
+
self,
|
|
173
|
+
method: str,
|
|
174
|
+
url: str,
|
|
175
|
+
*,
|
|
176
|
+
body: Any = None,
|
|
177
|
+
headers: dict[str, str] | None = None,
|
|
178
|
+
timeout_ms: int | None = None,
|
|
179
|
+
) -> JsonSuccess:
|
|
180
|
+
import json
|
|
181
|
+
|
|
182
|
+
req_headers = self._base_headers(accept="application/json", extra=headers)
|
|
183
|
+
content: bytes | None = None
|
|
184
|
+
if body is not None:
|
|
185
|
+
req_headers["Content-Type"] = "application/json"
|
|
186
|
+
content = json.dumps(body).encode("utf-8")
|
|
187
|
+
response = self._send(
|
|
188
|
+
method, url, headers=req_headers, content=content, timeout_ms=timeout_ms
|
|
189
|
+
)
|
|
190
|
+
return self._parse_json_envelope(response)
|
|
191
|
+
|
|
192
|
+
def request_binary(
|
|
193
|
+
self,
|
|
194
|
+
method: str,
|
|
195
|
+
url: str,
|
|
196
|
+
*,
|
|
197
|
+
body: Any = None,
|
|
198
|
+
content: bytes | None = None,
|
|
199
|
+
headers: dict[str, str] | None = None,
|
|
200
|
+
timeout_ms: int | None = None,
|
|
201
|
+
) -> BinaryResult:
|
|
202
|
+
req_headers = self._base_headers(accept="*/*", extra=headers)
|
|
203
|
+
raw_content = content
|
|
204
|
+
if body is not None:
|
|
205
|
+
req_headers.setdefault("Content-Type", "application/json")
|
|
206
|
+
import json
|
|
207
|
+
|
|
208
|
+
raw_content = json.dumps(body).encode("utf-8")
|
|
209
|
+
response = self._send(
|
|
210
|
+
method,
|
|
211
|
+
url,
|
|
212
|
+
headers=req_headers,
|
|
213
|
+
content=raw_content,
|
|
214
|
+
timeout_ms=timeout_ms,
|
|
215
|
+
)
|
|
216
|
+
if response.status_code == 304:
|
|
217
|
+
header_map = _header_map(response.headers)
|
|
218
|
+
if_none = (headers or {}).get("If-None-Match")
|
|
219
|
+
return BinaryResult(
|
|
220
|
+
data=b"",
|
|
221
|
+
content_type=response.headers.get(
|
|
222
|
+
"content-type", "application/octet-stream"
|
|
223
|
+
),
|
|
224
|
+
request_id=response.headers.get("x-request-id", ""),
|
|
225
|
+
headers=header_map,
|
|
226
|
+
etag=response.headers.get("etag") or if_none,
|
|
227
|
+
width=_num_header(response.headers, "x-image-width"),
|
|
228
|
+
height=_num_header(response.headers, "x-image-height"),
|
|
229
|
+
format=response.headers.get("x-image-format"),
|
|
230
|
+
transformation_id=response.headers.get("x-transformation-id"),
|
|
231
|
+
cache=response.headers.get("x-cache"),
|
|
232
|
+
pdf_page=_num_header(response.headers, "x-pdf-page"),
|
|
233
|
+
not_modified=True,
|
|
234
|
+
)
|
|
235
|
+
if response.is_error:
|
|
236
|
+
raise self._error_from_response(response)
|
|
237
|
+
return self._binary_from_response(response)
|
|
238
|
+
|
|
239
|
+
def request_multipart_json(
|
|
240
|
+
self,
|
|
241
|
+
method: str,
|
|
242
|
+
url: str,
|
|
243
|
+
*,
|
|
244
|
+
files: list[tuple[str, tuple[str, bytes, str]]],
|
|
245
|
+
upload_length: int | None = None,
|
|
246
|
+
headers: dict[str, str] | None = None,
|
|
247
|
+
timeout_ms: int | None = None,
|
|
248
|
+
) -> JsonSuccess:
|
|
249
|
+
req_headers = self._base_headers(accept="application/json", extra=headers)
|
|
250
|
+
if upload_length is not None:
|
|
251
|
+
req_headers["X-Upload-Length"] = str(upload_length)
|
|
252
|
+
response = self._send(
|
|
253
|
+
method,
|
|
254
|
+
url,
|
|
255
|
+
headers=req_headers,
|
|
256
|
+
files=files,
|
|
257
|
+
timeout_ms=timeout_ms,
|
|
258
|
+
)
|
|
259
|
+
return self._parse_json_envelope(response)
|
|
260
|
+
|
|
261
|
+
def request_multipart_binary(
|
|
262
|
+
self,
|
|
263
|
+
method: str,
|
|
264
|
+
url: str,
|
|
265
|
+
*,
|
|
266
|
+
files: list[tuple[str, tuple[str, bytes, str]]],
|
|
267
|
+
upload_length: int | None = None,
|
|
268
|
+
headers: dict[str, str] | None = None,
|
|
269
|
+
timeout_ms: int | None = None,
|
|
270
|
+
) -> BinaryResult | JsonSuccess:
|
|
271
|
+
req_headers = self._base_headers(accept="*/*", extra=headers)
|
|
272
|
+
if upload_length is not None:
|
|
273
|
+
req_headers["X-Upload-Length"] = str(upload_length)
|
|
274
|
+
response = self._send(
|
|
275
|
+
method,
|
|
276
|
+
url,
|
|
277
|
+
headers=req_headers,
|
|
278
|
+
files=files,
|
|
279
|
+
timeout_ms=timeout_ms,
|
|
280
|
+
)
|
|
281
|
+
if response.is_error:
|
|
282
|
+
raise self._error_from_response(response)
|
|
283
|
+
ct = response.headers.get("content-type", "")
|
|
284
|
+
if "application/json" in ct:
|
|
285
|
+
return self._parse_json_envelope(response)
|
|
286
|
+
return self._binary_from_response(response)
|
|
287
|
+
|
|
288
|
+
def _base_headers(
|
|
289
|
+
self, *, accept: str, extra: dict[str, str] | None
|
|
290
|
+
) -> dict[str, str]:
|
|
291
|
+
headers = {
|
|
292
|
+
"Accept": accept,
|
|
293
|
+
"X-API-Key": self.config.api_key,
|
|
294
|
+
"User-Agent": self.config.user_agent,
|
|
295
|
+
}
|
|
296
|
+
if extra:
|
|
297
|
+
headers.update(extra)
|
|
298
|
+
return headers
|
|
299
|
+
|
|
300
|
+
def _timeout(self, timeout_ms: int | None) -> httpx.Timeout:
|
|
301
|
+
ms = self.config.timeout_ms if timeout_ms is None else timeout_ms
|
|
302
|
+
return httpx.Timeout(ms / 1000.0)
|
|
303
|
+
|
|
304
|
+
def _send(
|
|
305
|
+
self,
|
|
306
|
+
method: str,
|
|
307
|
+
url: str,
|
|
308
|
+
*,
|
|
309
|
+
headers: dict[str, str],
|
|
310
|
+
content: bytes | None = None,
|
|
311
|
+
files: list[tuple[str, tuple[str, bytes, str]]] | None = None,
|
|
312
|
+
timeout_ms: int | None = None,
|
|
313
|
+
) -> httpx.Response:
|
|
314
|
+
try:
|
|
315
|
+
return self._client.request(
|
|
316
|
+
method,
|
|
317
|
+
url,
|
|
318
|
+
headers=headers,
|
|
319
|
+
content=content,
|
|
320
|
+
files=files,
|
|
321
|
+
timeout=self._timeout(timeout_ms),
|
|
322
|
+
)
|
|
323
|
+
except httpx.TimeoutException as exc:
|
|
324
|
+
ms = self.config.timeout_ms if timeout_ms is None else timeout_ms
|
|
325
|
+
raise NetworkError(
|
|
326
|
+
f"Request timed out after {ms}ms",
|
|
327
|
+
cause=exc,
|
|
328
|
+
) from exc
|
|
329
|
+
except httpx.HTTPError as exc:
|
|
330
|
+
raise NetworkError(
|
|
331
|
+
str(exc) or "Network request failed",
|
|
332
|
+
cause=exc,
|
|
333
|
+
) from exc
|
|
334
|
+
|
|
335
|
+
def _binary_from_response(self, response: httpx.Response) -> BinaryResult:
|
|
336
|
+
return BinaryResult(
|
|
337
|
+
data=response.content,
|
|
338
|
+
content_type=response.headers.get(
|
|
339
|
+
"content-type", "application/octet-stream"
|
|
340
|
+
),
|
|
341
|
+
request_id=response.headers.get("x-request-id", ""),
|
|
342
|
+
headers=_header_map(response.headers),
|
|
343
|
+
etag=response.headers.get("etag"),
|
|
344
|
+
width=_num_header(response.headers, "x-image-width"),
|
|
345
|
+
height=_num_header(response.headers, "x-image-height"),
|
|
346
|
+
format=response.headers.get("x-image-format"),
|
|
347
|
+
transformation_id=response.headers.get("x-transformation-id"),
|
|
348
|
+
cache=response.headers.get("x-cache"),
|
|
349
|
+
pdf_page=_num_header(response.headers, "x-pdf-page"),
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
def _parse_json_envelope(self, response: httpx.Response) -> JsonSuccess:
|
|
353
|
+
request_id = response.headers.get("x-request-id")
|
|
354
|
+
try:
|
|
355
|
+
payload = response.json()
|
|
356
|
+
except ValueError as exc:
|
|
357
|
+
if response.is_error:
|
|
358
|
+
raise map_http_error(
|
|
359
|
+
status=response.status_code,
|
|
360
|
+
message=response.reason_phrase or "Request failed",
|
|
361
|
+
request_id=request_id,
|
|
362
|
+
) from exc
|
|
363
|
+
raise NetworkError(
|
|
364
|
+
"Response was not valid JSON",
|
|
365
|
+
request_id=request_id,
|
|
366
|
+
cause=exc,
|
|
367
|
+
) from exc
|
|
368
|
+
if response.is_error:
|
|
369
|
+
raise self._error_from_payload(response.status_code, payload, request_id)
|
|
370
|
+
if (
|
|
371
|
+
not isinstance(payload, dict)
|
|
372
|
+
or payload.get("success") is not True
|
|
373
|
+
or "data" not in payload
|
|
374
|
+
):
|
|
375
|
+
raise map_http_error(
|
|
376
|
+
status=response.status_code,
|
|
377
|
+
message="Unexpected success envelope",
|
|
378
|
+
request_id=request_id,
|
|
379
|
+
)
|
|
380
|
+
meta = payload.get("meta") or {}
|
|
381
|
+
return JsonSuccess(
|
|
382
|
+
data=payload["data"],
|
|
383
|
+
meta={
|
|
384
|
+
"requestId": meta.get("requestId") or request_id or "",
|
|
385
|
+
"page": meta.get("page"),
|
|
386
|
+
"limit": meta.get("limit"),
|
|
387
|
+
"total": meta.get("total"),
|
|
388
|
+
"pages": meta.get("pages"),
|
|
389
|
+
},
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
def _error_from_response(self, response: httpx.Response) -> Exception:
|
|
393
|
+
request_id = response.headers.get("x-request-id")
|
|
394
|
+
try:
|
|
395
|
+
payload = response.json()
|
|
396
|
+
except ValueError:
|
|
397
|
+
return map_http_error(
|
|
398
|
+
status=response.status_code,
|
|
399
|
+
message=response.reason_phrase or "Request failed",
|
|
400
|
+
request_id=request_id,
|
|
401
|
+
)
|
|
402
|
+
return self._error_from_payload(response.status_code, payload, request_id)
|
|
403
|
+
|
|
404
|
+
def _error_from_payload(
|
|
405
|
+
self,
|
|
406
|
+
status: int,
|
|
407
|
+
payload: Any,
|
|
408
|
+
request_id: str | None,
|
|
409
|
+
) -> Exception:
|
|
410
|
+
err = None
|
|
411
|
+
if isinstance(payload, dict) and isinstance(payload.get("error"), dict):
|
|
412
|
+
err = payload["error"]
|
|
413
|
+
meta = payload.get("meta") if isinstance(payload.get("meta"), dict) else {}
|
|
414
|
+
request_id = meta.get("requestId") or request_id
|
|
415
|
+
return map_http_error(
|
|
416
|
+
status=status,
|
|
417
|
+
code=err.get("code") if err else None,
|
|
418
|
+
message=(err.get("message") if err else None) or "Request failed",
|
|
419
|
+
request_id=request_id,
|
|
420
|
+
details=err.get("details") if err else None,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _encode_segment(segment: str) -> str:
|
|
425
|
+
return quote(segment, safe="")
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def build_query(params: dict[str, Any]) -> str:
|
|
429
|
+
items: list[tuple[str, str]] = []
|
|
430
|
+
for key, value in params.items():
|
|
431
|
+
if value is None or value == "":
|
|
432
|
+
continue
|
|
433
|
+
if isinstance(value, bool):
|
|
434
|
+
items.append((key, "true" if value else "false"))
|
|
435
|
+
else:
|
|
436
|
+
items.append((key, str(value)))
|
|
437
|
+
return urlencode(items)
|