"""ZPLCraft saved-label API client. Save as zplcraft.py. Python 3.10+, standard library only.""" import json import math import re from dataclasses import dataclass from urllib.error import HTTPError from urllib.parse import urlsplit, urlencode from urllib.request import Request, HTTPRedirectHandler, build_opener class ZplCraftError(Exception): def __init__(self, status, details=None, retry_after=None): details = details if isinstance(details, dict) else {} super().__init__(details.get("error") or f"ZPLCraft request failed ({status}).") self.status = status self.code = details.get("code") self.fields = details.get("fields", []) self.required_scope = details.get("required_scope") self.retry_after = retry_after @dataclass(frozen=True) class Artifact: data: bytes content_type: str output_format: str | None label_count: int | None @property def text(self): return self.data.decode("utf-8") class _NoRedirect(HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): return None # Never forward the API key to a redirected destination. def _label_path(label_id): if not isinstance(label_id, str) or not re.fullmatch( r"[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", label_id, re.I ): raise ValueError("A label UUID is required.") return f"/labels/{label_id}" class ZplCraftClient: def __init__(self, base_url, api_key, timeout=30): url = urlsplit(base_url) local = url.hostname in ("localhost", "127.0.0.1", "::1") if (not url.hostname or (url.scheme != "https" and not (url.scheme == "http" and local)) or url.username or url.password or url.query or url.fragment): raise ValueError("Use an HTTPS API base URL without credentials, query or fragment.") if not isinstance(api_key, str) or not re.fullmatch(r"zplc_[0-9a-f]{48}", api_key): raise ValueError("A valid ZPLCraft API key is required.") if not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or timeout <= 0: raise ValueError("timeout must be a positive number of seconds.") self._base_url = base_url.rstrip("/") self._api_key = api_key self._timeout = timeout self._opener = build_opener(_NoRedirect()) def _request(self, path, body=None, as_json=False): headers = {"x-api-key": self._api_key} data = None if body is not None: headers["Content-Type"] = "application/json" data = json.dumps(body).encode("utf-8") request = Request(self._base_url + path, headers=headers, data=data) try: response = self._opener.open(request, timeout=self._timeout) except HTTPError as error: with error: try: details = json.loads(error.read()) except (ValueError, UnicodeError): details = {} raw_retry = error.headers.get("Retry-After", "") retry = int(raw_retry) if re.fullmatch(r"[0-9]+", raw_retry) else None raise ZplCraftError(error.code, details, retry) from None with response: data = response.read() if as_json: return json.loads(data) count = response.headers.get("X-ZPLCraft-Label-Count", "") return Artifact( data=data, content_type=response.headers.get("Content-Type", "").split(";")[0].strip(), output_format=response.headers.get("X-ZPLCraft-Output-Format"), label_count=int(count) if re.fullmatch(r"[0-9]+", count) else None, ) def list_labels(self, limit=50, offset=0): if type(limit) is not int or not 1 <= limit <= 100 or type(offset) is not int or not 0 <= offset <= 10000: raise ValueError("limit must be 1–100 and offset must be 0–10000.") return self._request("/labels?" + urlencode({"limit": limit, "offset": offset}), as_json=True) def get_label(self, label_id): return self._request(_label_path(label_id), as_json=True) def get_stored_zpl(self, label_id): return self._request(_label_path(label_id) + "/zpl") def render(self, label_id, variables=None, format="zpl"): if format not in ("zpl", "png", "pdf"): raise ValueError("format must be zpl, png or pdf.") return self._request(_label_path(label_id) + "/render", {"variables": variables if variables is not None else {}, "format": format}) def create_print_job(self, label_id, *, variables=None, records=None, copies=1): """Return ZPL for your transport. Does not dispatch a printer job or retry.""" if variables is not None and records is not None: raise ValueError("Use variables or records, not both.") body = {"copies": copies} if records is not None: body["records"] = records else: body["variables"] = variables if variables is not None else {} return self._request(_label_path(label_id) + "/print", body)