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 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
@@ -0,0 +1,3 @@
1
+ """SDK version."""
2
+
3
+ __version__ = "1.0.0"
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
+ [![PyPI version](https://img.shields.io/pypi/v/zpljet.svg)](https://pypi.org/project/zpljet/)
37
+ [![CI](https://github.com/zpljet/zpljet-python/actions/workflows/ci.yml/badge.svg)](https://github.com/zpljet/zpljet-python/actions/workflows/ci.yml)
38
+ [![license](https://img.shields.io/pypi/l/zpljet.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.