Files
CSG-Picker-Shipper-Helper/app/shipengine.py
T
2026-07-06 00:24:30 -05:00

332 lines
12 KiB
Python

"""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_,
}