"""ShipEngine API client — USPS label creation. Authentication uses a simple API key header (no OAuth / token refresh). Keys prefixed with TEST_ create non-mailable test labels with no postage charged — same endpoint, same code path. """ from __future__ import annotations import logging import re from dataclasses import dataclass import requests log = logging.getLogger(__name__) _BASE_URL = "https://api.shipengine.com" # Our internal package codes → ShipEngine package codes _PACKAGE_CODES: dict[str, str] = { "package": "package", "large_envelope": "large_envelope_or_flat", "letter": "letter", "thick_envelope": "thick_envelope", } @dataclass class LabelResult: label_id: str tracking_number: str label_pdf_path: str class ShipEngineClient: """Thin wrapper around the ShipEngine REST API.""" def __init__(self, api_key: str) -> None: self._key = api_key self._headers = { "API-Key": api_key, "Content-Type": "application/json", } # ── Address validation ───────────────────────────────────────────────── def validate_address(self, addr: dict) -> dict: """Validate and normalise a structured address dict. Input keys: name, company_name, address_line1, address_line2, city, state_province, postal_code Returns the same key set with corrected values, plus a 'validation_status' key ("verified" | "warning" | "unverified" | "error"). """ payload = [{ "name": addr.get("name", ""), "company_name": addr.get("company_name", ""), "address_line1": addr.get("address_line1", ""), "address_line2": addr.get("address_line2", ""), "city_locality": addr.get("city", ""), "state_province": addr.get("state_province", ""), "postal_code": addr.get("postal_code", ""), "country_code": "US", }] resp = requests.post( f"{_BASE_URL}/v1/addresses/validate", json=payload, headers=self._headers, timeout=15, ) if not resp.ok: raise RuntimeError(_format_error(resp, "Address validation")) result = resp.json() if not result: return addr item = result[0] status = item.get("status", "unverified") matched = item.get("matched_address") or {} return { "name": matched.get("name", addr.get("name", "")), "company_name": matched.get("company_name", addr.get("company_name", "")), "address_line1": matched.get("address_line1", addr.get("address_line1", "")), "address_line2": matched.get("address_line2", addr.get("address_line2", "")), "city": matched.get("city_locality", addr.get("city", "")), "state_province": matched.get("state_province", addr.get("state_province", "")), "postal_code": matched.get("postal_code", addr.get("postal_code", "")), "validation_status": status, } # ── Rate fetch ───────────────────────────────────────────────────────── def get_rate( self, from_zip: str, to_zip: str, weight_oz: int, service_code: str, package_code: str, dim_l: float = 0, dim_w: float = 0, dim_h: float = 0, ) -> float | None: """Return total shipment cost in USD, or None on any failure.""" package: dict = { "weight": {"value": max(1, weight_oz), "unit": "ounce"}, } if dim_l and dim_w and dim_h: package["dimensions"] = { "length": dim_l, "width": dim_w, "height": dim_h, "unit": "inch", } if package_code and package_code != "package": package["package_code"] = _PACKAGE_CODES.get(package_code, package_code) payload = { "rate_options": {"service_codes": [service_code]}, "shipment": { "service_code": service_code, "ship_from": {"postal_code": from_zip, "country_code": "US"}, "ship_to": {"postal_code": to_zip, "country_code": "US"}, "packages": [package], }, } try: resp = requests.post( f"{_BASE_URL}/v1/rates", json=payload, headers=self._headers, timeout=12, ) if not resp.ok: return None data = resp.json() rate_list = (data.get("rate_response") or {}).get("rates") or [] if not rate_list: return None total = rate_list[0].get("shipping_amount", {}).get("amount") return float(total) if total is not None else None except Exception as exc: log.debug("Rate fetch failed: %s", exc) return None # ── Label creation ───────────────────────────────────────────────────── def create_label(self, data: dict, parsed_addr: dict) -> LabelResult: """Create a USPS label and save the PDF locally. `data` is the dict from _UspsPanel._on_create(). `parsed_addr` is a structured address dict (from parse_address_text + validate_address). """ pkg_code = _PACKAGE_CODES.get(data["package_code"], data["package_code"]) package: dict = { "weight": {"value": max(1, data["weight_oz"]), "unit": "ounce"}, "package_code": pkg_code, } if data.get("dim_l") and data.get("dim_w") and data.get("dim_h"): package["dimensions"] = { "length": data["dim_l"], "width": data["dim_w"], "height": data["dim_h"], "unit": "inch", } if data.get("print_message"): package["label_messages"] = {"reference1": data["print_message"][:60]} ship_to: dict = { "name": parsed_addr.get("name", ""), "company_name": parsed_addr.get("company_name", ""), "address_line1": parsed_addr.get("address_line1", ""), "address_line2": parsed_addr.get("address_line2", ""), "city_locality": parsed_addr.get("city", ""), "state_province": parsed_addr.get("state_province", ""), "postal_code": parsed_addr.get("postal_code", ""), "country_code": "US", } if data.get("email"): ship_to["email"] = data["email"] payload = { "label_format": "pdf", "label_layout": "4x6", "shipment": { "service_code": data["service_code"], "ship_from": self._shipper, "ship_to": ship_to, "packages": [package], }, } resp = requests.post( f"{_BASE_URL}/v1/labels", json=payload, headers=self._headers, timeout=30, ) if not resp.ok: raise RuntimeError(_format_error(resp, "Label creation")) result = resp.json() label_id = result.get("label_id", "") tracking = result.get("tracking_number", "") pdf_url = (result.get("label_download") or {}).get("pdf", "") if not pdf_url: raise RuntimeError( f"Label created (tracking: {tracking}) but ShipEngine returned no PDF URL." ) pdf_resp = requests.get(pdf_url, timeout=20) pdf_resp.raise_for_status() from datetime import datetime from .paths import appdata_dir labels_dir = appdata_dir() / "labels" labels_dir.mkdir(exist_ok=True) stamp = datetime.now().strftime("%Y%m%d_%H%M%S") label_path = labels_dir / f"USPS_{tracking or 'label'}_{stamp}.pdf" label_path.write_bytes(pdf_resp.content) log.info("USPS label created: tracking=%s label=%s", tracking, label_path) return LabelResult( label_id = label_id, tracking_number = tracking, label_pdf_path = str(label_path), ) def set_shipper(self, cfg) -> None: """Build the ship_from block from config and cache it.""" self._shipper = { "name": cfg.usps_shipper_name, "company_name": cfg.usps_shipper_company, "phone": cfg.usps_shipper_phone, "address_line1": cfg.usps_shipper_address1, "city_locality": cfg.usps_shipper_city, "state_province": cfg.usps_shipper_state, "postal_code": cfg.usps_shipper_zip, "country_code": "US", } # ── Error formatting ──────────────────────────────────────────────────────── def _format_error(resp: requests.Response, context: str) -> str: status = resp.status_code try: body = resp.json() errors = body.get("errors") or [] msgs = [e.get("message", "") for e in errors if e.get("message")] detail = " | ".join(msgs) if msgs else str(body) except Exception: detail = resp.text or "(no body)" return f"{context} failed — ShipEngine HTTP {status}: {detail}" # ── Freeform address parser ───────────────────────────────────────────────── _CSZ_RE = re.compile( r'^(.+?),?\s+([A-Za-z]{2})\s+(\d{5}(?:-\d{4})?)$' ) _STREET_RE = re.compile(r'^\d') def parse_address_text(text: str) -> dict: """Parse a freeform US address block into a structured dict. Handles the common copy-paste format: Name [Company] Street Address [Apt/Suite] City, ST ZIP """ lines = [l.strip() for l in text.strip().splitlines() if l.strip()] if not lines: return {} # Find the city/state/zip line (search from bottom) csz_idx = None city = state = zip_ = "" for i in range(len(lines) - 1, -1, -1): m = _CSZ_RE.match(lines[i]) if m: csz_idx = i city = m.group(1).strip() state = m.group(2).upper() zip_ = m.group(3) break if csz_idx is None: # Can't find city/state/zip — return what we have return {"address_line1": lines[0]} before = lines[:csz_idx] name = company = addr1 = addr2 = "" if len(before) == 0: pass elif len(before) == 1: addr1 = before[0] elif len(before) == 2: name = before[0] addr1 = before[1] elif len(before) == 3: name = before[0] # Middle line: company or address depending on whether last line is street if _STREET_RE.match(before[2]): company = before[1] addr1 = before[2] else: addr1 = before[1] addr2 = before[2] else: name = before[0] company = before[1] addr1 = before[2] addr2 = before[3] return { "name": name, "company_name": company, "address_line1": addr1, "address_line2": addr2, "city": city, "state_province": state, "postal_code": zip_, }