zpljet 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.
- zpljet/__init__.py +41 -0
- zpljet/_client.py +499 -0
- zpljet/_errors.py +172 -0
- zpljet/_types.py +43 -0
- zpljet/_version.py +3 -0
- zpljet/py.typed +0 -0
- zpljet-1.0.0.dist-info/METADATA +240 -0
- zpljet-1.0.0.dist-info/RECORD +10 -0
- zpljet-1.0.0.dist-info/WHEEL +4 -0
- zpljet-1.0.0.dist-info/licenses/LICENSE +21 -0
zpljet/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Official Python SDK for the ZPLJet API."""
|
|
2
|
+
|
|
3
|
+
from ._client import AsyncZplJet, Transport, TransportResponse, ZplJet
|
|
4
|
+
from ._errors import (
|
|
5
|
+
APIConnectionError,
|
|
6
|
+
APIError,
|
|
7
|
+
APITimeoutError,
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
BadRequestError,
|
|
10
|
+
ConversionFailedError,
|
|
11
|
+
PayloadTooLargeError,
|
|
12
|
+
PermissionDeniedError,
|
|
13
|
+
QuotaExceededError,
|
|
14
|
+
RateLimitError,
|
|
15
|
+
ServiceUnavailableError,
|
|
16
|
+
ZplJetError,
|
|
17
|
+
)
|
|
18
|
+
from ._types import HostedLabel, LabelData
|
|
19
|
+
from ._version import __version__
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"APIConnectionError",
|
|
23
|
+
"APIError",
|
|
24
|
+
"APITimeoutError",
|
|
25
|
+
"AsyncZplJet",
|
|
26
|
+
"AuthenticationError",
|
|
27
|
+
"BadRequestError",
|
|
28
|
+
"ConversionFailedError",
|
|
29
|
+
"HostedLabel",
|
|
30
|
+
"LabelData",
|
|
31
|
+
"PayloadTooLargeError",
|
|
32
|
+
"PermissionDeniedError",
|
|
33
|
+
"QuotaExceededError",
|
|
34
|
+
"RateLimitError",
|
|
35
|
+
"ServiceUnavailableError",
|
|
36
|
+
"Transport",
|
|
37
|
+
"TransportResponse",
|
|
38
|
+
"ZplJet",
|
|
39
|
+
"ZplJetError",
|
|
40
|
+
"__version__",
|
|
41
|
+
]
|
zpljet/_client.py
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"""ZPLJet API client — sync (:class:`ZplJet`) and async (:class:`AsyncZplJet`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import email.utils
|
|
7
|
+
import http.client
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import random
|
|
11
|
+
import socket
|
|
12
|
+
import time
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.parse
|
|
15
|
+
import urllib.request
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any, Callable, Literal, cast, overload
|
|
19
|
+
|
|
20
|
+
from ._errors import APIConnectionError, APIError, APITimeoutError, ZplJetError
|
|
21
|
+
from ._types import HostedLabel, LabelData
|
|
22
|
+
from ._version import __version__
|
|
23
|
+
|
|
24
|
+
__all__ = ["AsyncZplJet", "Transport", "TransportResponse", "ZplJet"]
|
|
25
|
+
|
|
26
|
+
DEFAULT_BASE_URL = "https://api.zpljet.com"
|
|
27
|
+
DEFAULT_TIMEOUT = 60.0
|
|
28
|
+
DEFAULT_MAX_RETRIES = 2
|
|
29
|
+
MAX_RETRIES_CAP = 10
|
|
30
|
+
|
|
31
|
+
_MAX_RETRY_DELAY = 30.0
|
|
32
|
+
_BASE_RETRY_DELAY = 0.5
|
|
33
|
+
_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "[::1]"})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _normalize_max_retries(value: int) -> int:
|
|
37
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
38
|
+
raise ValueError("max_retries must be a finite integer >= 0")
|
|
39
|
+
return min(value, MAX_RETRIES_CAP)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _normalize_timeout(value: float) -> float:
|
|
43
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
44
|
+
raise ValueError("timeout must be a finite number > 0")
|
|
45
|
+
timeout = float(value)
|
|
46
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
47
|
+
raise ValueError("timeout must be a finite number > 0")
|
|
48
|
+
return timeout
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _assert_secure_base_url(base_url: str, allow_insecure_http: bool) -> None:
|
|
52
|
+
"""Refuse to send the API key over plaintext http to a non-loopback host."""
|
|
53
|
+
parsed = urllib.parse.urlparse(base_url)
|
|
54
|
+
if parsed.scheme == "https":
|
|
55
|
+
return
|
|
56
|
+
if parsed.scheme == "http":
|
|
57
|
+
host = (parsed.hostname or "").lower()
|
|
58
|
+
if allow_insecure_http or host in _LOOPBACK_HOSTS:
|
|
59
|
+
return
|
|
60
|
+
raise ZplJetError(
|
|
61
|
+
f"Refusing to send your API key over plaintext http:// to {parsed.netloc}. "
|
|
62
|
+
"Use https, or pass allow_insecure_http=True for local/testing."
|
|
63
|
+
)
|
|
64
|
+
raise ZplJetError(f"Unsupported base_url scheme: {parsed.scheme or '(none)'}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class TransportResponse:
|
|
69
|
+
"""A raw HTTP response, as returned by a :data:`Transport`."""
|
|
70
|
+
|
|
71
|
+
status: int
|
|
72
|
+
headers: Mapping[str, str]
|
|
73
|
+
body: bytes
|
|
74
|
+
|
|
75
|
+
def header(self, name: str) -> str | None:
|
|
76
|
+
"""Case-insensitive header lookup."""
|
|
77
|
+
lowered = name.lower()
|
|
78
|
+
for key, value in self.headers.items():
|
|
79
|
+
if key.lower() == lowered:
|
|
80
|
+
return value
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
#: Sends one POST request: ``(url, body, headers, timeout) -> TransportResponse``.
|
|
85
|
+
#: Must raise :class:`APITimeoutError` / :class:`APIConnectionError` on
|
|
86
|
+
#: transport failures. Inject a custom one for proxies or tests.
|
|
87
|
+
Transport = Callable[[str, bytes, Mapping[str, str], float], TransportResponse]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
91
|
+
def redirect_request(
|
|
92
|
+
self,
|
|
93
|
+
req: urllib.request.Request,
|
|
94
|
+
fp: Any,
|
|
95
|
+
code: int,
|
|
96
|
+
msg: str,
|
|
97
|
+
headers: Any,
|
|
98
|
+
newurl: str,
|
|
99
|
+
) -> urllib.request.Request | None:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
_NO_REDIRECT_OPENER = urllib.request.build_opener(_NoRedirectHandler())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _urllib_transport(
|
|
107
|
+
url: str, body: bytes, headers: Mapping[str, str], timeout: float
|
|
108
|
+
) -> TransportResponse:
|
|
109
|
+
"""Send one request with the standard library."""
|
|
110
|
+
request = urllib.request.Request(url, data=body, headers=dict(headers), method="POST")
|
|
111
|
+
try:
|
|
112
|
+
with _NO_REDIRECT_OPENER.open(request, timeout=timeout) as response:
|
|
113
|
+
return TransportResponse(
|
|
114
|
+
status=response.status,
|
|
115
|
+
headers=dict(response.headers.items()),
|
|
116
|
+
body=response.read(),
|
|
117
|
+
)
|
|
118
|
+
except urllib.error.HTTPError as exc:
|
|
119
|
+
with exc:
|
|
120
|
+
try:
|
|
121
|
+
error_body = exc.read()
|
|
122
|
+
except (http.client.HTTPException, OSError) as read_exc:
|
|
123
|
+
raise APIConnectionError(
|
|
124
|
+
f"Request to {url} failed while reading the response: {read_exc}"
|
|
125
|
+
) from read_exc
|
|
126
|
+
return TransportResponse(
|
|
127
|
+
status=exc.code,
|
|
128
|
+
headers=dict(exc.headers.items()) if exc.headers else {},
|
|
129
|
+
body=error_body,
|
|
130
|
+
)
|
|
131
|
+
except socket.timeout as exc:
|
|
132
|
+
raise APITimeoutError(f"Request to {url} timed out after {timeout}s") from exc
|
|
133
|
+
except urllib.error.URLError as exc:
|
|
134
|
+
if isinstance(exc.reason, socket.timeout):
|
|
135
|
+
raise APITimeoutError(f"Request to {url} timed out after {timeout}s") from exc
|
|
136
|
+
raise APIConnectionError(f"Request to {url} failed: {exc.reason}") from exc
|
|
137
|
+
except http.client.HTTPException as exc:
|
|
138
|
+
raise APIConnectionError(f"Request to {url} failed: {exc!r}") from exc
|
|
139
|
+
except OSError as exc:
|
|
140
|
+
raise APIConnectionError(f"Request to {url} failed: {exc}") from exc
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class ZplJet:
|
|
144
|
+
"""Synchronous ZPLJet API client with automatic retries.
|
|
145
|
+
|
|
146
|
+
:param api_key: Your ZPLJet API key (``zpl_…``), created in the dashboard
|
|
147
|
+
at https://zpljet.com/dashboard. Keep it server-side.
|
|
148
|
+
:param base_url: API origin. Defaults to ``https://api.zpljet.com``.
|
|
149
|
+
:param timeout: Per-attempt timeout in seconds. Defaults to ``60``.
|
|
150
|
+
:param max_retries: How many times a failed request is automatically
|
|
151
|
+
retried. Defaults to ``2``. Set ``0`` to disable.
|
|
152
|
+
:param transport: Custom :data:`Transport` — useful for proxies,
|
|
153
|
+
instrumentation, and tests. Defaults to a stdlib ``urllib`` transport.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
def __init__(
|
|
157
|
+
self,
|
|
158
|
+
api_key: str,
|
|
159
|
+
*,
|
|
160
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
161
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
162
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
163
|
+
allow_insecure_http: bool = False,
|
|
164
|
+
transport: Transport | None = None,
|
|
165
|
+
) -> None:
|
|
166
|
+
if not isinstance(api_key, str) or not api_key.strip():
|
|
167
|
+
raise ZplJetError(
|
|
168
|
+
"Missing API key. Pass ZplJet(api_key='zpl_…') — create one at "
|
|
169
|
+
"https://zpljet.com/dashboard."
|
|
170
|
+
)
|
|
171
|
+
self.api_key = api_key.strip()
|
|
172
|
+
self.base_url = base_url.rstrip("/")
|
|
173
|
+
_assert_secure_base_url(self.base_url, allow_insecure_http)
|
|
174
|
+
self.timeout = _normalize_timeout(timeout)
|
|
175
|
+
self.max_retries = _normalize_max_retries(max_retries)
|
|
176
|
+
self._transport: Transport = transport or _urllib_transport
|
|
177
|
+
|
|
178
|
+
@overload
|
|
179
|
+
def convert(
|
|
180
|
+
self,
|
|
181
|
+
zpl: str,
|
|
182
|
+
*,
|
|
183
|
+
dpmm: int | None = ...,
|
|
184
|
+
width_mm: float | None = ...,
|
|
185
|
+
height_mm: float | None = ...,
|
|
186
|
+
format: Literal["pdf", "png"] | None = ...,
|
|
187
|
+
output: Literal["url"],
|
|
188
|
+
timeout: float | None = ...,
|
|
189
|
+
max_retries: int | None = ...,
|
|
190
|
+
) -> HostedLabel: ...
|
|
191
|
+
|
|
192
|
+
@overload
|
|
193
|
+
def convert(
|
|
194
|
+
self,
|
|
195
|
+
zpl: str,
|
|
196
|
+
*,
|
|
197
|
+
dpmm: int | None = ...,
|
|
198
|
+
width_mm: float | None = ...,
|
|
199
|
+
height_mm: float | None = ...,
|
|
200
|
+
format: Literal["pdf", "png"] | None = ...,
|
|
201
|
+
output: Literal["data", None] = ...,
|
|
202
|
+
timeout: float | None = ...,
|
|
203
|
+
max_retries: int | None = ...,
|
|
204
|
+
) -> LabelData: ...
|
|
205
|
+
|
|
206
|
+
def convert(
|
|
207
|
+
self,
|
|
208
|
+
zpl: str,
|
|
209
|
+
*,
|
|
210
|
+
dpmm: int | None = None,
|
|
211
|
+
width_mm: float | None = None,
|
|
212
|
+
height_mm: float | None = None,
|
|
213
|
+
format: Literal["pdf", "png"] | None = None,
|
|
214
|
+
output: Literal["data", "url"] | None = None,
|
|
215
|
+
timeout: float | None = None,
|
|
216
|
+
max_retries: int | None = None,
|
|
217
|
+
) -> LabelData | HostedLabel:
|
|
218
|
+
"""Convert ZPL to a PDF or PNG.
|
|
219
|
+
|
|
220
|
+
With ``output="data"`` (the default) the API returns the raw file
|
|
221
|
+
bytes and stores nothing. With ``output="url"`` (paid plans) the file
|
|
222
|
+
is hosted and a public link is returned.
|
|
223
|
+
|
|
224
|
+
:param zpl: Raw ZPL — one or more ``^XA…^XZ`` label blocks. Max 512 KB.
|
|
225
|
+
:param dpmm: Print density in dots/mm: 6, 8 (default, 203 dpi),
|
|
226
|
+
12 (300 dpi), or 24 (600 dpi).
|
|
227
|
+
:param width_mm: Physical label width in millimeters (default 101.6).
|
|
228
|
+
:param height_mm: Physical label height in millimeters (default 152.4).
|
|
229
|
+
:param format: ``"pdf"`` (default) or ``"png"``.
|
|
230
|
+
:param output: ``"data"`` (default) or ``"url"`` (paid plans).
|
|
231
|
+
:param timeout: Per-attempt timeout override for this call, seconds.
|
|
232
|
+
:param max_retries: Retry-count override for this call.
|
|
233
|
+
|
|
234
|
+
:raises BadRequestError: the ZPL or parameters failed validation (400)
|
|
235
|
+
:raises AuthenticationError: missing or invalid API key (401)
|
|
236
|
+
:raises QuotaExceededError: monthly quota used up (402)
|
|
237
|
+
:raises PermissionDeniedError: hosting not allowed for this account (403)
|
|
238
|
+
:raises RateLimitError: rate limit exceeded, after retries (429)
|
|
239
|
+
:raises ConversionFailedError: the engine could not render the ZPL (502)
|
|
240
|
+
:raises APIConnectionError: network failure or timeout, after retries
|
|
241
|
+
"""
|
|
242
|
+
return self._convert(
|
|
243
|
+
zpl,
|
|
244
|
+
dpmm=dpmm,
|
|
245
|
+
width_mm=width_mm,
|
|
246
|
+
height_mm=height_mm,
|
|
247
|
+
format=format,
|
|
248
|
+
output=output,
|
|
249
|
+
timeout=timeout,
|
|
250
|
+
max_retries=max_retries,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def _convert(
|
|
254
|
+
self,
|
|
255
|
+
zpl: str,
|
|
256
|
+
*,
|
|
257
|
+
dpmm: int | None,
|
|
258
|
+
width_mm: float | None,
|
|
259
|
+
height_mm: float | None,
|
|
260
|
+
format: Literal["pdf", "png"] | None,
|
|
261
|
+
output: Literal["data", "url"] | None,
|
|
262
|
+
timeout: float | None,
|
|
263
|
+
max_retries: int | None,
|
|
264
|
+
) -> LabelData | HostedLabel:
|
|
265
|
+
body: dict[str, Any] = {"zpl": zpl}
|
|
266
|
+
if dpmm is not None:
|
|
267
|
+
body["dpmm"] = dpmm
|
|
268
|
+
if width_mm is not None:
|
|
269
|
+
body["widthMm"] = width_mm
|
|
270
|
+
if height_mm is not None:
|
|
271
|
+
body["heightMm"] = height_mm
|
|
272
|
+
if format is not None:
|
|
273
|
+
body["format"] = format
|
|
274
|
+
if output is not None:
|
|
275
|
+
body["output"] = output
|
|
276
|
+
|
|
277
|
+
response = self._request_with_retries("/v1/convert", body, timeout, max_retries)
|
|
278
|
+
|
|
279
|
+
if output == "url":
|
|
280
|
+
return _parse_hosted_label(response.body)
|
|
281
|
+
return LabelData(
|
|
282
|
+
data=response.body,
|
|
283
|
+
content_type=response.header("content-type") or "application/octet-stream",
|
|
284
|
+
id=response.header("x-conversion-id") or "",
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def _request_with_retries(
|
|
288
|
+
self,
|
|
289
|
+
path: str,
|
|
290
|
+
body: dict[str, Any],
|
|
291
|
+
timeout: float | None,
|
|
292
|
+
max_retries: int | None,
|
|
293
|
+
) -> TransportResponse:
|
|
294
|
+
"""POST ``body`` as JSON, retrying transient failures."""
|
|
295
|
+
url = f"{self.base_url}{path}"
|
|
296
|
+
payload = json.dumps(body).encode("utf-8")
|
|
297
|
+
headers = {
|
|
298
|
+
"Content-Type": "application/json",
|
|
299
|
+
"X-API-Key": self.api_key,
|
|
300
|
+
"User-Agent": f"zpljet-python/{__version__}",
|
|
301
|
+
}
|
|
302
|
+
retries = (
|
|
303
|
+
self.max_retries
|
|
304
|
+
if max_retries is None
|
|
305
|
+
else _normalize_max_retries(max_retries)
|
|
306
|
+
)
|
|
307
|
+
attempt_timeout = self.timeout if timeout is None else _normalize_timeout(timeout)
|
|
308
|
+
|
|
309
|
+
attempt = 0
|
|
310
|
+
while True:
|
|
311
|
+
error: APIError | APIConnectionError
|
|
312
|
+
header_retry_after: float | None = None
|
|
313
|
+
try:
|
|
314
|
+
response = self._transport(url, payload, headers, attempt_timeout)
|
|
315
|
+
if 200 <= response.status < 300:
|
|
316
|
+
return response
|
|
317
|
+
error = APIError.from_response(response.status, _parse_error_body(response.body))
|
|
318
|
+
header_retry_after = _parse_retry_after_header(response.header("retry-after"))
|
|
319
|
+
except APIConnectionError as exc:
|
|
320
|
+
error = exc
|
|
321
|
+
|
|
322
|
+
if attempt >= retries or not _is_retryable(error):
|
|
323
|
+
raise error
|
|
324
|
+
_sleep(_retry_delay(error, attempt, header_retry_after))
|
|
325
|
+
attempt += 1
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class AsyncZplJet:
|
|
329
|
+
"""Async ZPLJet client backed by :func:`asyncio.to_thread`."""
|
|
330
|
+
|
|
331
|
+
def __init__(
|
|
332
|
+
self,
|
|
333
|
+
api_key: str,
|
|
334
|
+
*,
|
|
335
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
336
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
337
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
338
|
+
allow_insecure_http: bool = False,
|
|
339
|
+
transport: Transport | None = None,
|
|
340
|
+
) -> None:
|
|
341
|
+
self._client = ZplJet(
|
|
342
|
+
api_key,
|
|
343
|
+
base_url=base_url,
|
|
344
|
+
timeout=timeout,
|
|
345
|
+
max_retries=max_retries,
|
|
346
|
+
allow_insecure_http=allow_insecure_http,
|
|
347
|
+
transport=transport,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
@property
|
|
351
|
+
def base_url(self) -> str:
|
|
352
|
+
return self._client.base_url
|
|
353
|
+
|
|
354
|
+
@overload
|
|
355
|
+
async def convert(
|
|
356
|
+
self,
|
|
357
|
+
zpl: str,
|
|
358
|
+
*,
|
|
359
|
+
dpmm: int | None = ...,
|
|
360
|
+
width_mm: float | None = ...,
|
|
361
|
+
height_mm: float | None = ...,
|
|
362
|
+
format: Literal["pdf", "png"] | None = ...,
|
|
363
|
+
output: Literal["url"],
|
|
364
|
+
timeout: float | None = ...,
|
|
365
|
+
max_retries: int | None = ...,
|
|
366
|
+
) -> HostedLabel: ...
|
|
367
|
+
|
|
368
|
+
@overload
|
|
369
|
+
async def convert(
|
|
370
|
+
self,
|
|
371
|
+
zpl: str,
|
|
372
|
+
*,
|
|
373
|
+
dpmm: int | None = ...,
|
|
374
|
+
width_mm: float | None = ...,
|
|
375
|
+
height_mm: float | None = ...,
|
|
376
|
+
format: Literal["pdf", "png"] | None = ...,
|
|
377
|
+
output: Literal["data", None] = ...,
|
|
378
|
+
timeout: float | None = ...,
|
|
379
|
+
max_retries: int | None = ...,
|
|
380
|
+
) -> LabelData: ...
|
|
381
|
+
|
|
382
|
+
async def convert(
|
|
383
|
+
self,
|
|
384
|
+
zpl: str,
|
|
385
|
+
*,
|
|
386
|
+
dpmm: int | None = None,
|
|
387
|
+
width_mm: float | None = None,
|
|
388
|
+
height_mm: float | None = None,
|
|
389
|
+
format: Literal["pdf", "png"] | None = None,
|
|
390
|
+
output: Literal["data", "url"] | None = None,
|
|
391
|
+
timeout: float | None = None,
|
|
392
|
+
max_retries: int | None = None,
|
|
393
|
+
) -> LabelData | HostedLabel:
|
|
394
|
+
"""Async :meth:`ZplJet.convert` — same parameters and errors."""
|
|
395
|
+
return await asyncio.to_thread(
|
|
396
|
+
self._client._convert,
|
|
397
|
+
zpl,
|
|
398
|
+
dpmm=dpmm,
|
|
399
|
+
width_mm=width_mm,
|
|
400
|
+
height_mm=height_mm,
|
|
401
|
+
format=format,
|
|
402
|
+
output=output,
|
|
403
|
+
timeout=timeout,
|
|
404
|
+
max_retries=max_retries,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _parse_error_body(body: bytes) -> dict[str, Any]:
|
|
409
|
+
"""Parse the structured ``{"error": {…}}`` body; tolerate anything else."""
|
|
410
|
+
try:
|
|
411
|
+
parsed = json.loads(body.decode("utf-8"))
|
|
412
|
+
except (ValueError, UnicodeDecodeError):
|
|
413
|
+
return {}
|
|
414
|
+
if isinstance(parsed, dict) and isinstance(parsed.get("error"), dict):
|
|
415
|
+
return cast(dict[str, Any], parsed["error"])
|
|
416
|
+
return {}
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _parse_success_object(body: bytes) -> dict[str, Any]:
|
|
420
|
+
try:
|
|
421
|
+
parsed = json.loads(body.decode("utf-8"))
|
|
422
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
423
|
+
raise ZplJetError("Invalid JSON in successful API response") from exc
|
|
424
|
+
if not isinstance(parsed, dict):
|
|
425
|
+
raise ZplJetError("Invalid payload in successful API response")
|
|
426
|
+
return parsed
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _required_string(payload: dict[str, Any], key: str) -> str:
|
|
430
|
+
value = payload.get(key)
|
|
431
|
+
if not isinstance(value, str) or not value:
|
|
432
|
+
raise ZplJetError(f"Invalid {key} in successful API response")
|
|
433
|
+
return value
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _parse_hosted_label(body: bytes) -> HostedLabel:
|
|
437
|
+
payload = _parse_success_object(body)
|
|
438
|
+
pages = payload.get("pages")
|
|
439
|
+
retention_days = payload.get("retentionDays")
|
|
440
|
+
if not isinstance(pages, int) or isinstance(pages, bool) or pages < 1:
|
|
441
|
+
raise ZplJetError("Invalid pages in successful API response")
|
|
442
|
+
if (
|
|
443
|
+
not isinstance(retention_days, int)
|
|
444
|
+
or isinstance(retention_days, bool)
|
|
445
|
+
or retention_days < 1
|
|
446
|
+
):
|
|
447
|
+
raise ZplJetError("Invalid retentionDays in successful API response")
|
|
448
|
+
return HostedLabel(
|
|
449
|
+
id=_required_string(payload, "id"),
|
|
450
|
+
url=_required_string(payload, "url"),
|
|
451
|
+
pages=pages,
|
|
452
|
+
retention_days=retention_days,
|
|
453
|
+
expires_at=_required_string(payload, "expiresAt"),
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _is_retryable(error: APIError | APIConnectionError) -> bool:
|
|
458
|
+
"""Return whether a failure is transient."""
|
|
459
|
+
if isinstance(error, APIConnectionError):
|
|
460
|
+
return True
|
|
461
|
+
if error.status == 429:
|
|
462
|
+
return True
|
|
463
|
+
return error.status >= 500 and error.code != "conversion_failed"
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _parse_retry_after_header(value: str | None) -> float | None:
|
|
467
|
+
"""Parse delta-seconds or an HTTP date."""
|
|
468
|
+
if not value:
|
|
469
|
+
return None
|
|
470
|
+
try:
|
|
471
|
+
return max(0.0, float(value))
|
|
472
|
+
except ValueError:
|
|
473
|
+
pass
|
|
474
|
+
try:
|
|
475
|
+
when = email.utils.parsedate_to_datetime(value)
|
|
476
|
+
except (TypeError, ValueError):
|
|
477
|
+
return None
|
|
478
|
+
return max(0.0, when.timestamp() - time.time())
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _retry_delay(
|
|
482
|
+
error: APIError | APIConnectionError,
|
|
483
|
+
attempt: int,
|
|
484
|
+
header_retry_after: float | None = None,
|
|
485
|
+
) -> float:
|
|
486
|
+
"""Calculate the next retry delay."""
|
|
487
|
+
if isinstance(error, APIError):
|
|
488
|
+
retry_after = error.raw.get("retryAfter")
|
|
489
|
+
if isinstance(retry_after, (int, float)) and not isinstance(retry_after, bool):
|
|
490
|
+
return min(max(float(retry_after), 0.0), _MAX_RETRY_DELAY)
|
|
491
|
+
if header_retry_after is not None:
|
|
492
|
+
return min(header_retry_after, _MAX_RETRY_DELAY)
|
|
493
|
+
backoff = _BASE_RETRY_DELAY * (2.0**attempt)
|
|
494
|
+
return min(backoff + backoff * 0.25 * random.random(), _MAX_RETRY_DELAY)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _sleep(seconds: float) -> None:
|
|
498
|
+
if seconds > 0:
|
|
499
|
+
time.sleep(seconds)
|
zpljet/_errors.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Typed errors for the ZPLJet API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"APIConnectionError",
|
|
9
|
+
"APIError",
|
|
10
|
+
"APITimeoutError",
|
|
11
|
+
"AuthenticationError",
|
|
12
|
+
"BadRequestError",
|
|
13
|
+
"ConversionFailedError",
|
|
14
|
+
"PayloadTooLargeError",
|
|
15
|
+
"PermissionDeniedError",
|
|
16
|
+
"QuotaExceededError",
|
|
17
|
+
"RateLimitError",
|
|
18
|
+
"ServiceUnavailableError",
|
|
19
|
+
"ZplJetError",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ZplJetError(Exception):
|
|
24
|
+
"""Base class for every error raised by this SDK."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class APIConnectionError(ZplJetError):
|
|
28
|
+
"""A network failure prevented a usable response."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, message: str = "Connection error") -> None:
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class APITimeoutError(APIConnectionError):
|
|
35
|
+
"""A single attempt exceeded the configured timeout."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, message: str = "Request timed out") -> None:
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class APIError(ZplJetError):
|
|
42
|
+
"""An HTTP error response from the API."""
|
|
43
|
+
|
|
44
|
+
status: int
|
|
45
|
+
"""HTTP status code."""
|
|
46
|
+
|
|
47
|
+
code: str | None
|
|
48
|
+
"""Stable machine-readable code — safe to branch on."""
|
|
49
|
+
|
|
50
|
+
doc_url: str | None
|
|
51
|
+
"""Link to the docs entry for this code."""
|
|
52
|
+
|
|
53
|
+
raw: dict[str, Any]
|
|
54
|
+
"""The raw parsed ``error`` object, including any context fields."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, status: int, message: str, raw: dict[str, Any] | None = None) -> None:
|
|
57
|
+
super().__init__(message)
|
|
58
|
+
raw = raw or {}
|
|
59
|
+
self.status = status
|
|
60
|
+
self.code = raw.get("code") if isinstance(raw.get("code"), str) else None
|
|
61
|
+
self.doc_url = raw.get("docUrl") if isinstance(raw.get("docUrl"), str) else None
|
|
62
|
+
self.raw = raw
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def message(self) -> str:
|
|
66
|
+
"""The human-readable error message (may change; don't parse it)."""
|
|
67
|
+
return str(self.args[0]) if self.args else ""
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def from_response(status: int, raw: dict[str, Any]) -> APIError:
|
|
71
|
+
"""Build the most specific error subclass for a response."""
|
|
72
|
+
message = raw.get("message")
|
|
73
|
+
if not isinstance(message, str) or not message:
|
|
74
|
+
message = f"HTTP {status} error from the ZPLJet API"
|
|
75
|
+
cls = _ERROR_CLASSES.get(str(raw.get("code")), APIError)
|
|
76
|
+
return cls(status, message, raw)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class BadRequestError(APIError):
|
|
80
|
+
"""400 ``invalid_request`` response."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, status: int, message: str, raw: dict[str, Any] | None = None) -> None:
|
|
83
|
+
super().__init__(status, message, raw)
|
|
84
|
+
self.param: str | None = _str_field(self.raw, "param")
|
|
85
|
+
"""Dot-path of the invalid field, e.g. ``"zpl"`` or ``"dpmm"``."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class AuthenticationError(APIError):
|
|
89
|
+
"""401 ``missing_api_key`` or ``invalid_api_key`` response."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class PayloadTooLargeError(APIError):
|
|
93
|
+
"""413 ``payload_too_large`` — request body exceeded the API limit."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class QuotaExceededError(APIError):
|
|
97
|
+
"""402 ``quota_exceeded`` — the monthly conversion quota is used up."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, status: int, message: str, raw: dict[str, Any] | None = None) -> None:
|
|
100
|
+
super().__init__(status, message, raw)
|
|
101
|
+
self.plan: str | None = _str_field(self.raw, "plan")
|
|
102
|
+
"""Plan id the account is on (e.g. ``"free"``)."""
|
|
103
|
+
self.quota: int | None = _int_field(self.raw, "quota")
|
|
104
|
+
"""Monthly quota for that plan."""
|
|
105
|
+
self.used: int | None = _int_field(self.raw, "used")
|
|
106
|
+
"""Conversions used so far this month."""
|
|
107
|
+
self.resets_at: str | None = _str_field(self.raw, "resetsAt")
|
|
108
|
+
"""ISO 8601 UTC timestamp — when the quota resets."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class PermissionDeniedError(APIError):
|
|
112
|
+
"""403 ``hosting_not_allowed`` or ``no_retention_enforced`` response."""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class RateLimitError(APIError):
|
|
116
|
+
"""429 ``rate_limit_exceeded`` response."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, status: int, message: str, raw: dict[str, Any] | None = None) -> None:
|
|
119
|
+
super().__init__(status, message, raw)
|
|
120
|
+
self.retry_after: float | None = _num_field(self.raw, "retryAfter")
|
|
121
|
+
"""Seconds to wait before retrying."""
|
|
122
|
+
self.retry_at: str | None = _str_field(self.raw, "retryAt")
|
|
123
|
+
"""ISO 8601 UTC timestamp — when to retry."""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class ConversionFailedError(APIError):
|
|
127
|
+
"""502 ``conversion_failed`` response."""
|
|
128
|
+
|
|
129
|
+
def __init__(self, status: int, message: str, raw: dict[str, Any] | None = None) -> None:
|
|
130
|
+
super().__init__(status, message, raw)
|
|
131
|
+
self.conversion_id: str | None = _str_field(self.raw, "conversionId")
|
|
132
|
+
"""Id of the failed attempt — quote it when contacting support."""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ServiceUnavailableError(APIError):
|
|
136
|
+
"""503 ``service_unavailable`` response."""
|
|
137
|
+
|
|
138
|
+
def __init__(self, status: int, message: str, raw: dict[str, Any] | None = None) -> None:
|
|
139
|
+
super().__init__(status, message, raw)
|
|
140
|
+
self.retry_after: float | None = _num_field(self.raw, "retryAfter")
|
|
141
|
+
"""Seconds to wait before retrying."""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
_ERROR_CLASSES: dict[str, type[APIError]] = {
|
|
145
|
+
"invalid_request": BadRequestError,
|
|
146
|
+
"missing_api_key": AuthenticationError,
|
|
147
|
+
"invalid_api_key": AuthenticationError,
|
|
148
|
+
"payload_too_large": PayloadTooLargeError,
|
|
149
|
+
"quota_exceeded": QuotaExceededError,
|
|
150
|
+
"hosting_not_allowed": PermissionDeniedError,
|
|
151
|
+
"no_retention_enforced": PermissionDeniedError,
|
|
152
|
+
"rate_limit_exceeded": RateLimitError,
|
|
153
|
+
"conversion_failed": ConversionFailedError,
|
|
154
|
+
"service_unavailable": ServiceUnavailableError,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _str_field(raw: dict[str, Any], key: str) -> str | None:
|
|
159
|
+
value = raw.get(key)
|
|
160
|
+
return value if isinstance(value, str) else None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _int_field(raw: dict[str, Any], key: str) -> int | None:
|
|
164
|
+
value = raw.get(key)
|
|
165
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _num_field(raw: dict[str, Any], key: str) -> float | None:
|
|
169
|
+
value = raw.get(key)
|
|
170
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
171
|
+
return None
|
|
172
|
+
return float(value)
|
zpljet/_types.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Result types for the ZPLJet API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
__all__ = ["HostedLabel", "LabelData"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class LabelData:
|
|
12
|
+
"""Result of a conversion with ``output="data"`` (the default)."""
|
|
13
|
+
|
|
14
|
+
data: bytes
|
|
15
|
+
"""The rendered file bytes (PDF or PNG)."""
|
|
16
|
+
|
|
17
|
+
content_type: str
|
|
18
|
+
"""``"application/pdf"`` or ``"image/png"``."""
|
|
19
|
+
|
|
20
|
+
id: str
|
|
21
|
+
"""Conversion id (from the ``X-Conversion-Id`` response header)."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class HostedLabel:
|
|
26
|
+
"""Result of a conversion with ``output="url"`` (hosted, paid plans)."""
|
|
27
|
+
|
|
28
|
+
id: str
|
|
29
|
+
"""Conversion id."""
|
|
30
|
+
|
|
31
|
+
url: str
|
|
32
|
+
"""Public URL to the hosted file. Works until the file is deleted at
|
|
33
|
+
``expires_at``."""
|
|
34
|
+
|
|
35
|
+
pages: int
|
|
36
|
+
"""Number of pages rendered (one per ``^XA…^XZ`` block)."""
|
|
37
|
+
|
|
38
|
+
retention_days: int
|
|
39
|
+
"""How many days the file is retained."""
|
|
40
|
+
|
|
41
|
+
expires_at: str
|
|
42
|
+
"""ISO 8601 UTC timestamp — when the hosted file is deleted and its URL
|
|
43
|
+
stops working."""
|
zpljet/_version.py
ADDED
zpljet/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zpljet
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python SDK for the ZPLJet API — fast ZPL to PDF/PNG conversion
|
|
5
|
+
Project-URL: Homepage, https://zpljet.com/docs
|
|
6
|
+
Project-URL: Documentation, https://zpljet.com/docs/sdks
|
|
7
|
+
Project-URL: Repository, https://github.com/zpljet/zpljet-python
|
|
8
|
+
Project-URL: Changelog, https://github.com/zpljet/zpljet-python/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: ZPLJet <support@zpljet.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: barcode,label,pdf,png,printing,shipping-label,zebra,zpl,zpljet
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: build>=1; extra == 'dev'
|
|
26
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# zpljet
|
|
33
|
+
|
|
34
|
+
Official Python SDK for the [ZPLJet](https://zpljet.com) API — fast ZPL → PDF/PNG conversion.
|
|
35
|
+
|
|
36
|
+
[](https://pypi.org/project/zpljet/)
|
|
37
|
+
[](https://github.com/zpljet/zpljet-python/actions/workflows/ci.yml)
|
|
38
|
+
[](https://github.com/zpljet/zpljet-python/blob/main/LICENSE)
|
|
39
|
+
|
|
40
|
+
- **Zero dependencies** — a single small client on top of the stdlib
|
|
41
|
+
- **Fully typed** (`py.typed`) — parameters, results, and every API error code
|
|
42
|
+
- **Reliable by default** — automatic retries with exponential backoff (honoring `Retry-After`), per-request timeouts, typed exceptions
|
|
43
|
+
- **Sync and async** — `ZplJet` for scripts and servers, `AsyncZplJet` for asyncio code
|
|
44
|
+
- Python ≥ 3.9
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
Choose one:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
pip install zpljet
|
|
52
|
+
uv add zpljet
|
|
53
|
+
poetry add zpljet
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Quickstart
|
|
57
|
+
|
|
58
|
+
Create an API key in the [dashboard](https://zpljet.com/dashboard) (keys look like `zpl_…`), then:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
import os
|
|
62
|
+
from pathlib import Path
|
|
63
|
+
|
|
64
|
+
from zpljet import ZplJet
|
|
65
|
+
|
|
66
|
+
zpljet = ZplJet(api_key=os.environ["ZPLJET_API_KEY"])
|
|
67
|
+
|
|
68
|
+
label = zpljet.convert(zpl="^XA^FO50,50^A0N,50,50^FDHello^FS^XZ")
|
|
69
|
+
|
|
70
|
+
Path("label.pdf").write_bytes(label.data)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
> **Keep your API key server-side.** Anyone with the key can spend your quota.
|
|
74
|
+
|
|
75
|
+
## Usage
|
|
76
|
+
|
|
77
|
+
### Convert to PDF or PNG
|
|
78
|
+
|
|
79
|
+
`convert()` accepts every parameter of [`POST /v1/convert`](https://zpljet.com/docs/api-reference):
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
label = zpljet.convert(
|
|
83
|
+
zpl="^XA^FO50,50^A0N,50,50^FDHello^FS^XZ",
|
|
84
|
+
format="png", # "pdf" (default) | "png"
|
|
85
|
+
dpmm=12, # 6 | 8 (default, 203 dpi) | 12 (300 dpi) | 24 (600 dpi)
|
|
86
|
+
width_mm=101.6, # label width, default 4 in
|
|
87
|
+
height_mm=152.4, # label height, default 6 in
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
label.data # bytes — the file
|
|
91
|
+
label.content_type # "application/pdf" | "image/png"
|
|
92
|
+
label.id # conversion id (shows up in your dashboard)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Hosted URLs (paid plans)
|
|
96
|
+
|
|
97
|
+
Pass `output="url"` to have ZPLJet host the file and return a public link
|
|
98
|
+
instead of the bytes. Files are retained for your account's retention window
|
|
99
|
+
(a dashboard setting, up to your plan's maximum).
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
hosted = zpljet.convert(zpl="^XA^FO50,50^A0N,50,50^FDHello^FS^XZ", output="url")
|
|
103
|
+
|
|
104
|
+
hosted.url # public URL to the PDF (works until the file is deleted)
|
|
105
|
+
hosted.pages # pages rendered (one per ^XA…^XZ block)
|
|
106
|
+
hosted.retention_days # how long the file is kept
|
|
107
|
+
hosted.expires_at # when the file is deleted and the URL stops working (ISO 8601, UTC)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The return type narrows automatically: `output="url"` gives a `HostedLabel`,
|
|
111
|
+
everything else a `LabelData`.
|
|
112
|
+
|
|
113
|
+
### Async
|
|
114
|
+
|
|
115
|
+
`AsyncZplJet` has the identical interface for asyncio code:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from zpljet import AsyncZplJet
|
|
119
|
+
|
|
120
|
+
zpljet = AsyncZplJet(api_key=os.environ["ZPLJET_API_KEY"])
|
|
121
|
+
label = await zpljet.convert(zpl="^XA^FO50,50^A0N,50,50^FDHello^FS^XZ")
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The async client runs the dependency-free transport in a worker thread. Bound
|
|
125
|
+
large batches with a semaphore; see
|
|
126
|
+
[`examples/05_async_batch.py`](https://github.com/zpljet/zpljet-python/blob/main/examples/05_async_batch.py).
|
|
127
|
+
|
|
128
|
+
### Error handling
|
|
129
|
+
|
|
130
|
+
Every API error code maps to a dedicated exception, so you branch with
|
|
131
|
+
`except` — no string matching:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from zpljet import (
|
|
135
|
+
ZplJet,
|
|
136
|
+
APIConnectionError,
|
|
137
|
+
BadRequestError,
|
|
138
|
+
ConversionFailedError,
|
|
139
|
+
QuotaExceededError,
|
|
140
|
+
RateLimitError,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
label = zpljet.convert(zpl=zpl)
|
|
145
|
+
except BadRequestError as err:
|
|
146
|
+
print(f"Invalid request ({err.param}): {err.message}")
|
|
147
|
+
except QuotaExceededError as err:
|
|
148
|
+
print(f"Quota used up ({err.used}/{err.quota}), resets {err.resets_at}")
|
|
149
|
+
except RateLimitError as err:
|
|
150
|
+
print(f"Rate limited — retry after {err.retry_after}s")
|
|
151
|
+
except ConversionFailedError as err:
|
|
152
|
+
print(f"Engine rejected the ZPL (conversion {err.conversion_id})")
|
|
153
|
+
except APIConnectionError as err:
|
|
154
|
+
print(f"Network problem: {err}")
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
| Exception | Status | `error.code` | Extra fields |
|
|
158
|
+
| --- | --- | --- | --- |
|
|
159
|
+
| `BadRequestError` | 400 | `invalid_request` | `param` |
|
|
160
|
+
| `AuthenticationError` | 401 | `missing_api_key` · `invalid_api_key` | — |
|
|
161
|
+
| `QuotaExceededError` | 402 | `quota_exceeded` | `plan`, `quota`, `used`, `resets_at` |
|
|
162
|
+
| `PermissionDeniedError` | 403 | `hosting_not_allowed` · `no_retention_enforced` | — |
|
|
163
|
+
| `PayloadTooLargeError` | 413 | `payload_too_large` | — |
|
|
164
|
+
| `RateLimitError` | 429 | `rate_limit_exceeded` | `retry_after`, `retry_at` |
|
|
165
|
+
| `ConversionFailedError` | 502 | `conversion_failed` | `conversion_id` |
|
|
166
|
+
| `ServiceUnavailableError` | 503 | `service_unavailable` | `retry_after` |
|
|
167
|
+
| `APIError` | any | anything else | `status`, `code`, `raw` |
|
|
168
|
+
| `APITimeoutError` | — | (an attempt timed out) | — |
|
|
169
|
+
| `APIConnectionError` | — | (request never got a response) | — |
|
|
170
|
+
|
|
171
|
+
All of these extend `ZplJetError`, and every HTTP error carries `status`,
|
|
172
|
+
`code`, `doc_url`, and the raw error payload in `raw`. Full code reference:
|
|
173
|
+
[zpljet.com/docs/errors](https://zpljet.com/docs/errors).
|
|
174
|
+
|
|
175
|
+
### Retries
|
|
176
|
+
|
|
177
|
+
Rate limits, transient 5xx responses, timeouts, and network failures retry up
|
|
178
|
+
to twice by default. Retries use exponential backoff and honor `Retry-After`.
|
|
179
|
+
`conversion_failed` is never retried.
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
# Client-wide
|
|
183
|
+
zpljet = ZplJet(api_key=key, max_retries=5)
|
|
184
|
+
|
|
185
|
+
# Or per request
|
|
186
|
+
zpljet.convert(zpl=zpl, max_retries=0) # fail fast
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Timeouts
|
|
190
|
+
|
|
191
|
+
Each attempt has a 60-second timeout by default:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
zpljet = ZplJet(api_key=key, timeout=10.0)
|
|
195
|
+
|
|
196
|
+
# Per request:
|
|
197
|
+
zpljet.convert(zpl=zpl, timeout=5.0)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
A timed-out attempt raises `APITimeoutError` (after retries).
|
|
201
|
+
|
|
202
|
+
### Configuration
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
zpljet = ZplJet(
|
|
206
|
+
api_key="zpl_…", # required
|
|
207
|
+
base_url="https://api.zpljet.com", # default
|
|
208
|
+
timeout=60.0, # per-attempt timeout, seconds
|
|
209
|
+
max_retries=2, # automatic retries
|
|
210
|
+
transport=my_transport, # custom transport (proxies, tests)
|
|
211
|
+
)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
The default transport uses stdlib `urllib`. For connection pooling, inject any
|
|
215
|
+
callable matching `(url, body, headers, timeout) -> TransportResponse`.
|
|
216
|
+
|
|
217
|
+
## Examples
|
|
218
|
+
|
|
219
|
+
Runnable scripts live in [`examples/`](https://github.com/zpljet/zpljet-python/tree/main/examples):
|
|
220
|
+
|
|
221
|
+
```sh
|
|
222
|
+
ZPLJET_API_KEY=zpl_… python examples/01_convert_to_pdf.py
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## Contributing & development
|
|
226
|
+
|
|
227
|
+
```sh
|
|
228
|
+
python -m venv .venv && source .venv/bin/activate
|
|
229
|
+
pip install -e ".[dev]"
|
|
230
|
+
|
|
231
|
+
ruff check .
|
|
232
|
+
mypy
|
|
233
|
+
pytest
|
|
234
|
+
|
|
235
|
+
ZPLJET_API_KEY=zpl_… pytest tests/test_e2e.py
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## License
|
|
239
|
+
|
|
240
|
+
[MIT](https://github.com/zpljet/zpljet-python/blob/main/LICENSE)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
zpljet/__init__.py,sha256=ywvsyJIMlws35IdKTaeViGtCacooOhHVxeOOVDK1gdE,939
|
|
2
|
+
zpljet/_client.py,sha256=La8mUbL_03PcLw8UGiD7WCo23dPgwPrpbNwvCdOjZp4,17459
|
|
3
|
+
zpljet/_errors.py,sha256=hdcOdJYhBSJGhSag5fYwkuzoNZQdEeEtIttCLUmry38,5932
|
|
4
|
+
zpljet/_types.py,sha256=-d-_xsLane3ujpTU4M-7kCz6f-pQXJOYTcLxOJJQjAc,1033
|
|
5
|
+
zpljet/_version.py,sha256=LJGvGF-KxOob9POgX213X7Wdm0sk0M4mvoHNldEZKwI,42
|
|
6
|
+
zpljet/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
zpljet-1.0.0.dist-info/METADATA,sha256=x7j3ks_BXH-HdneyKuDTKgwFsqlaN8FFCTlkhKZYzbY,7996
|
|
8
|
+
zpljet-1.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
9
|
+
zpljet-1.0.0.dist-info/licenses/LICENSE,sha256=wEwN5cT4ZYoQ83DaueRDJVnU1UumV8HAT1jcVv9n4S8,1063
|
|
10
|
+
zpljet-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ZPLJet
|
|
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.
|