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